Repository: gruntwork-io/terragrunt Branch: main Commit: bd643cd47632 Files: 4072 Total size: 7.3 MB Directory structure: gitextract_0weulqda/ ├── .coderabbit.yaml ├── .codespellrc ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-bug_report.md │ │ ├── 02-bad_error_message.md │ │ ├── 03-rfc.yml │ │ └── 04-feature-request.md │ ├── assets/ │ │ └── release-assets-config.json │ ├── cloud-nuke/ │ │ └── config.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ ├── scripts/ │ │ ├── announce-release.sh │ │ ├── gopls/ │ │ │ ├── check-for-changes.sh │ │ │ ├── create-issue.js │ │ │ ├── create-pr.js │ │ │ └── run.sh │ │ ├── release/ │ │ │ ├── README.md │ │ │ ├── check-release-exists.sh │ │ │ ├── create-archives.sh │ │ │ ├── generate-checksums.sh │ │ │ ├── generate-upload-summary.sh │ │ │ ├── get-version.sh │ │ │ ├── install-go-winres.ps1 │ │ │ ├── install-gon.sh │ │ │ ├── lib-release-config.sh │ │ │ ├── prepare-macos-artifacts.sh │ │ │ ├── prepare-windows-artifacts.ps1 │ │ │ ├── restore-p12-certificate.ps1 │ │ │ ├── set-permissions.sh │ │ │ ├── sign-checksums.sh │ │ │ ├── sign-macos-binaries.sh │ │ │ ├── sign-windows.ps1 │ │ │ ├── upload-assets.sh │ │ │ ├── verify-assets-uploaded.sh │ │ │ ├── verify-binaries-downloaded.sh │ │ │ ├── verify-files.sh │ │ │ ├── verify-smctl.ps1 │ │ │ └── verify-static-binary.sh │ │ └── setup/ │ │ ├── cas.sh │ │ ├── engine.sh │ │ ├── experiment-mode.sh │ │ ├── gcp.sh │ │ ├── generate-mocks.sh │ │ ├── generate-secrets.sh │ │ ├── mac-sign.sh │ │ ├── provider-cache-server.sh │ │ ├── run-setup-scripts.sh │ │ ├── sops.sh │ │ ├── ssh.sh │ │ ├── terraform-switch-latest.sh │ │ ├── terraform-switch.sh │ │ ├── tofu-switch.sh │ │ └── windows-setup.ps1 │ └── workflows/ │ ├── announce-release.yml │ ├── base-test.yml │ ├── build-no-proxy.yml │ ├── build.yml │ ├── ci.yml │ ├── cloud-nuke.yml │ ├── codespell.yml │ ├── flake.yml │ ├── fuzz.yml │ ├── go-mod-tidy-check.yml │ ├── gopls.yml │ ├── install-script-test.yml │ ├── integration-test.yml │ ├── license-check.yml │ ├── lint.yml │ ├── markdownlint.yml │ ├── oidc-integration-test.yml │ ├── precommit.yml │ ├── release.yml │ ├── sign-macos.yml │ ├── sign-windows.yml │ ├── stale.yml │ └── update-codified-remote-deps.yml ├── .gitignore ├── .golangci.yml ├── .gon_amd64.hcl ├── .gon_arm64.hcl ├── .licensei.toml ├── .markdownlint-cli2.yaml ├── .pre-commit-config.yaml ├── .sonarcloud.properties ├── CODEOWNERS ├── KEYS ├── LICENSE.txt ├── Makefile ├── README.md ├── SECURITY.md ├── docs/ │ ├── .gitignore │ ├── .vercelignore │ ├── README.md │ ├── astro.config.mjs │ ├── components.json │ ├── mise.toml │ ├── package.json │ ├── public/ │ │ ├── install │ │ ├── robots.txt │ │ └── schemas/ │ │ ├── auth-provider-cmd/ │ │ │ ├── v1/ │ │ │ │ └── schema.json │ │ │ └── v2/ │ │ │ └── schema.json │ │ └── run/ │ │ └── report/ │ │ ├── v1/ │ │ │ └── schema.json │ │ ├── v2/ │ │ │ └── schema.json │ │ ├── v3/ │ │ │ └── schema.json │ │ └── v4/ │ │ └── schema.json │ ├── src/ │ │ ├── assets/ │ │ │ └── icons/ │ │ │ └── terragrunt-icon-accent.astro │ │ ├── components/ │ │ │ ├── Command.astro │ │ │ ├── CompactFooter.astro │ │ │ ├── CompatibilityTable.astro │ │ │ ├── Flag.astro │ │ │ ├── Header.astro │ │ │ ├── InstallTab.astro │ │ │ ├── InstallTabs.astro │ │ │ ├── PageSidebar.astro │ │ │ ├── SectionSpacer.astro │ │ │ ├── SiteTitle.astro │ │ │ ├── SkipLink.astro │ │ │ ├── ThemeToggle.astro │ │ │ ├── dv-IconButton.astro │ │ │ ├── ui/ │ │ │ │ ├── Button.tsx │ │ │ │ └── ButtonLink.tsx │ │ │ └── vendored/ │ │ │ └── starlight/ │ │ │ ├── Card.astro │ │ │ ├── FileTree.astro │ │ │ ├── Icon.astro │ │ │ ├── Icons.ts │ │ │ ├── README.md │ │ │ ├── file-tree-icons.ts │ │ │ └── rehype-file-tree.ts │ │ ├── content/ │ │ │ └── docs/ │ │ │ ├── 01-getting-started/ │ │ │ │ ├── 01-quick-start.mdx │ │ │ │ ├── 02-overview.mdx │ │ │ │ ├── 03-install.mdx │ │ │ │ └── 04-terminology.md │ │ │ ├── 02-guides/ │ │ │ │ └── 01-terralith-to-terragrunt/ │ │ │ │ ├── 01-introduction.md │ │ │ │ ├── 02-overview.md │ │ │ │ ├── 03-setup.mdx │ │ │ │ ├── 04-step-1-starting-the-terralith.mdx │ │ │ │ ├── 05-step-2-refactoring.mdx │ │ │ │ ├── 06-step-3-adding-dev.mdx │ │ │ │ ├── 07-step-4-breaking-the-terralith.mdx │ │ │ │ ├── 08-step-5-adding-terragrunt.mdx │ │ │ │ ├── 09-step-6-breaking-the-terralith-further.mdx │ │ │ │ ├── 10-step-7-taking-advantage-of-terragrunt-stacks.mdx │ │ │ │ ├── 11-step-8-refactoring-state-with-terragrunt-stacks.mdx │ │ │ │ └── 12-wrap-up.mdx │ │ │ ├── 03-features/ │ │ │ │ ├── 01-units/ │ │ │ │ │ ├── 02-includes.mdx │ │ │ │ │ ├── 03-state-backend.mdx │ │ │ │ │ ├── 04-extra-arguments.mdx │ │ │ │ │ ├── 05-authentication.mdx │ │ │ │ │ ├── 06-hooks.mdx │ │ │ │ │ ├── 07-auto-init.mdx │ │ │ │ │ ├── 08-runtime-control.mdx │ │ │ │ │ ├── 09-engine.mdx │ │ │ │ │ └── index.mdx │ │ │ │ ├── 02-stacks/ │ │ │ │ │ ├── 02-implicit.mdx │ │ │ │ │ ├── 03-explicit.mdx │ │ │ │ │ ├── 04-stack-operations.mdx │ │ │ │ │ ├── 06-run-queue.mdx │ │ │ │ │ ├── 07-run-report.mdx │ │ │ │ │ └── index.mdx │ │ │ │ ├── 06-catalog/ │ │ │ │ │ ├── 02-tui.mdx │ │ │ │ │ ├── 03-scaffold.mdx │ │ │ │ │ └── index.mdx │ │ │ │ ├── 07-caching/ │ │ │ │ │ ├── 02-provider-cache-server.mdx │ │ │ │ │ ├── 03-auto-provider-cache-dir.mdx │ │ │ │ │ ├── 04-cas.mdx │ │ │ │ │ └── index.mdx │ │ │ │ └── 08-filter/ │ │ │ │ ├── 02-name.mdx │ │ │ │ ├── 03-path.mdx │ │ │ │ ├── 04-attributes.mdx │ │ │ │ ├── 05-graph.mdx │ │ │ │ ├── 06-git.mdx │ │ │ │ ├── 07-combining.mdx │ │ │ │ ├── 08-filters-file.mdx │ │ │ │ └── index.mdx │ │ │ ├── 04-reference/ │ │ │ │ ├── 01-hcl/ │ │ │ │ │ ├── 01-overview.mdx │ │ │ │ │ ├── 02-blocks.mdx │ │ │ │ │ ├── 03-attributes.mdx │ │ │ │ │ └── 04-functions.mdx │ │ │ │ ├── 02-cli/ │ │ │ │ │ ├── 01-overview.mdx │ │ │ │ │ ├── 02-commands/ │ │ │ │ │ │ ├── 0-opentofu-shortcuts.md │ │ │ │ │ │ ├── 0100-run.md │ │ │ │ │ │ ├── 0200-exec.md │ │ │ │ │ │ ├── 0500-catalog.md │ │ │ │ │ │ ├── 0600-scaffold.md │ │ │ │ │ │ ├── 0700-find.md │ │ │ │ │ │ ├── 0800-list.md │ │ │ │ │ │ ├── 1100-render.md │ │ │ │ │ │ ├── backend/ │ │ │ │ │ │ │ ├── 0300-bootstrap.md │ │ │ │ │ │ │ ├── 0301-migrate.md │ │ │ │ │ │ │ └── 0302-delete.md │ │ │ │ │ │ ├── dag/ │ │ │ │ │ │ │ └── 1000-graph.md │ │ │ │ │ │ ├── hcl/ │ │ │ │ │ │ │ ├── 0900-fmt.md │ │ │ │ │ │ │ └── 0901-validate.md │ │ │ │ │ │ ├── info/ │ │ │ │ │ │ │ └── 1200-print.md │ │ │ │ │ │ └── stack/ │ │ │ │ │ │ ├── 0400-generate.md │ │ │ │ │ │ ├── 0401-run.md │ │ │ │ │ │ ├── 0402-output.md │ │ │ │ │ │ └── 0403-clean.md │ │ │ │ │ └── 98-global-flags.mdx │ │ │ │ ├── 03-strict-controls.mdx │ │ │ │ ├── 04-experiments.md │ │ │ │ ├── 05-supported-versions.mdx │ │ │ │ ├── 06-lock-files.mdx │ │ │ │ ├── 07-logging/ │ │ │ │ │ ├── 01-overview.md │ │ │ │ │ └── 02-formatting.md │ │ │ │ └── 08-terragrunt-cache.md │ │ │ ├── 05-community/ │ │ │ │ ├── 01-contributing.mdx │ │ │ │ ├── 02-support.md │ │ │ │ └── 03-license.md │ │ │ ├── 06-troubleshooting/ │ │ │ │ ├── 01-debugging.mdx │ │ │ │ ├── 02-open-telemetry.md │ │ │ │ └── 03-performance.mdx │ │ │ ├── 07-process/ │ │ │ │ ├── 01-1-0-guarantees.mdx │ │ │ │ ├── 02-cli-rules.mdx │ │ │ │ └── 03-releases.mdx │ │ │ └── 08-migrate/ │ │ │ ├── 01-migrating-from-root-terragrunt-hcl.md │ │ │ ├── 02-upgrading-to-terragrunt-0-19-x.md │ │ │ ├── 03-cli-redesign.md │ │ │ ├── 04-terragrunt-stacks.mdx │ │ │ ├── 05-bare-include.md │ │ │ └── 06-deprecated-attributes.mdx │ │ ├── content.config.ts │ │ ├── data/ │ │ │ ├── commands/ │ │ │ │ ├── backend/ │ │ │ │ │ ├── bootstrap.mdx │ │ │ │ │ ├── delete.mdx │ │ │ │ │ └── migrate.mdx │ │ │ │ ├── catalog.mdx │ │ │ │ ├── dag/ │ │ │ │ │ └── graph.mdx │ │ │ │ ├── exec.mdx │ │ │ │ ├── find.mdx │ │ │ │ ├── hcl/ │ │ │ │ │ ├── fmt.mdx │ │ │ │ │ └── validate.mdx │ │ │ │ ├── info/ │ │ │ │ │ └── print.mdx │ │ │ │ ├── list.mdx │ │ │ │ ├── opentofu-shortcuts.mdx │ │ │ │ ├── render.mdx │ │ │ │ ├── run.mdx │ │ │ │ ├── scaffold.mdx │ │ │ │ └── stack/ │ │ │ │ ├── clean.mdx │ │ │ │ ├── generate.mdx │ │ │ │ ├── output.mdx │ │ │ │ └── run.mdx │ │ │ ├── compatibility/ │ │ │ │ └── compatibility.json │ │ │ └── flags/ │ │ │ ├── all.mdx │ │ │ ├── auth-provider-cmd.mdx │ │ │ ├── backend-bootstrap-all.mdx │ │ │ ├── backend-bootstrap-config.mdx │ │ │ ├── backend-bootstrap-download-dir.mdx │ │ │ ├── backend-delete-all.mdx │ │ │ ├── backend-delete-config.mdx │ │ │ ├── backend-delete-download-dir.mdx │ │ │ ├── backend-delete-force.mdx │ │ │ ├── backend-migrate-config.mdx │ │ │ ├── backend-migrate-download-dir.mdx │ │ │ ├── backend-migrate-force.mdx │ │ │ ├── catalog-no-hooks.mdx │ │ │ ├── catalog-no-include-root.mdx │ │ │ ├── catalog-no-shell.mdx │ │ │ ├── catalog-root-file-name.mdx │ │ │ ├── config.mdx │ │ │ ├── dependency-fetch-output-from-state.mdx │ │ │ ├── destroy-dependencies-check.mdx │ │ │ ├── disable-bucket-update.mdx │ │ │ ├── disable-command-validation.mdx │ │ │ ├── download-dir.mdx │ │ │ ├── engine-cache-path.mdx │ │ │ ├── engine-log-level.mdx │ │ │ ├── engine-skip-check.mdx │ │ │ ├── experiment-mode.mdx │ │ │ ├── experiment.mdx │ │ │ ├── experimental-engine.mdx │ │ │ ├── fail-fast.mdx │ │ │ ├── feature.mdx │ │ │ ├── filter-affected.mdx │ │ │ ├── filter.mdx │ │ │ ├── filters-file.mdx │ │ │ ├── find-dag.mdx │ │ │ ├── find-dependencies.mdx │ │ │ ├── find-exclude.mdx │ │ │ ├── find-external.mdx │ │ │ ├── find-format.mdx │ │ │ ├── find-hidden.mdx │ │ │ ├── find-include.mdx │ │ │ ├── find-json.mdx │ │ │ ├── find-no-hidden.mdx │ │ │ ├── find-reading.mdx │ │ │ ├── graph.mdx │ │ │ ├── hcl-fmt-check.mdx │ │ │ ├── hcl-fmt-diff.mdx │ │ │ ├── hcl-fmt-exclude-dir.mdx │ │ │ ├── hcl-fmt-file.mdx │ │ │ ├── hcl-fmt-filter.mdx │ │ │ ├── hcl-fmt-stdin.mdx │ │ │ ├── hcl-validate-inputs.mdx │ │ │ ├── hcl-validate-json.mdx │ │ │ ├── hcl-validate-show-config-path.mdx │ │ │ ├── hcl-validate-strict.mdx │ │ │ ├── help.mdx │ │ │ ├── iam-assume-role-duration.mdx │ │ │ ├── iam-assume-role-session-name.mdx │ │ │ ├── iam-assume-role-web-identity-token.mdx │ │ │ ├── iam-assume-role.mdx │ │ │ ├── in-download-dir.mdx │ │ │ ├── inputs-debug.mdx │ │ │ ├── json-out-dir.mdx │ │ │ ├── list-dag.mdx │ │ │ ├── list-dependencies.mdx │ │ │ ├── list-external.mdx │ │ │ ├── list-format.mdx │ │ │ ├── list-hidden.mdx │ │ │ ├── list-long.mdx │ │ │ ├── list-no-hidden.mdx │ │ │ ├── list-tree.mdx │ │ │ ├── log-custom-format.mdx │ │ │ ├── log-disable.mdx │ │ │ ├── log-format.mdx │ │ │ ├── log-level.mdx │ │ │ ├── log-show-abs-paths.mdx │ │ │ ├── no-auto-approve.mdx │ │ │ ├── no-auto-init.mdx │ │ │ ├── no-auto-provider-cache-dir.mdx │ │ │ ├── no-auto-retry.mdx │ │ │ ├── no-color.mdx │ │ │ ├── no-dependency-fetch-output-from-state.mdx │ │ │ ├── no-destroy-dependencies-check.mdx │ │ │ ├── no-engine.mdx │ │ │ ├── no-filters-file.mdx │ │ │ ├── no-stack-generate.mdx │ │ │ ├── no-tip.mdx │ │ │ ├── no-tips.mdx │ │ │ ├── non-interactive.mdx │ │ │ ├── out-dir.mdx │ │ │ ├── parallelism.mdx │ │ │ ├── provider-cache-dir.mdx │ │ │ ├── provider-cache-hostname.mdx │ │ │ ├── provider-cache-port.mdx │ │ │ ├── provider-cache-registry-names.mdx │ │ │ ├── provider-cache-token.mdx │ │ │ ├── provider-cache.mdx │ │ │ ├── queue-construct-as.mdx │ │ │ ├── queue-exclude-dir.mdx │ │ │ ├── queue-exclude-external.mdx │ │ │ ├── queue-excludes-file.mdx │ │ │ ├── queue-ignore-dag-order.mdx │ │ │ ├── queue-ignore-errors.mdx │ │ │ ├── queue-include-dir.mdx │ │ │ ├── queue-include-external.mdx │ │ │ ├── queue-include-units-reading.mdx │ │ │ ├── queue-strict-include.mdx │ │ │ ├── render-all.mdx │ │ │ ├── render-format.mdx │ │ │ ├── render-write.mdx │ │ │ ├── report-file.mdx │ │ │ ├── report-format.mdx │ │ │ ├── report-schema-file.mdx │ │ │ ├── scaffold-no-hooks.mdx │ │ │ ├── scaffold-no-include-root.mdx │ │ │ ├── scaffold-no-shell.mdx │ │ │ ├── scaffold-root-file-name.mdx │ │ │ ├── scaffold-var-file.mdx │ │ │ ├── scaffold-var.mdx │ │ │ ├── source-map.mdx │ │ │ ├── source-update.mdx │ │ │ ├── source.mdx │ │ │ ├── stack-generate-filter.mdx │ │ │ ├── stack-output-format.mdx │ │ │ ├── stack-output-json.mdx │ │ │ ├── stack-output-raw.mdx │ │ │ ├── strict-control.mdx │ │ │ ├── strict-mode.mdx │ │ │ ├── summary-disable.mdx │ │ │ ├── summary-per-unit.mdx │ │ │ ├── tf-forward-stdout.mdx │ │ │ ├── tf-path.mdx │ │ │ ├── units-that-include.mdx │ │ │ ├── use-partial-parse-config-cache.mdx │ │ │ ├── version-manager-file-name.mdx │ │ │ ├── version.mdx │ │ │ └── working-dir.mdx │ │ ├── fixtures/ │ │ │ └── terralith-to-terragrunt/ │ │ │ ├── .gitignore │ │ │ ├── app/ │ │ │ │ └── best-cat/ │ │ │ │ ├── index.js │ │ │ │ ├── package.json │ │ │ │ ├── script.js │ │ │ │ ├── styles.css │ │ │ │ └── template.html │ │ │ ├── mise.toml │ │ │ └── walkthrough/ │ │ │ ├── step-1-starting-the-terralith/ │ │ │ │ └── live/ │ │ │ │ ├── .auto.tfvars.example │ │ │ │ ├── backend.tf │ │ │ │ ├── data.tf │ │ │ │ ├── ddb.tf │ │ │ │ ├── iam.tf │ │ │ │ ├── lambda.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── providers.tf │ │ │ │ ├── s3.tf │ │ │ │ ├── vars-optional.tf │ │ │ │ ├── vars-required.tf │ │ │ │ └── versions.tf │ │ │ ├── step-2-refactoring/ │ │ │ │ ├── catalog/ │ │ │ │ │ └── modules/ │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── live/ │ │ │ │ ├── .auto.tfvars.example │ │ │ │ ├── backend.tf │ │ │ │ ├── main.tf │ │ │ │ ├── moved.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── providers.tf │ │ │ │ ├── vars-optional.tf │ │ │ │ ├── vars-required.tf │ │ │ │ └── versions.tf │ │ │ ├── step-3-adding-dev/ │ │ │ │ ├── catalog/ │ │ │ │ │ └── modules/ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── live/ │ │ │ │ ├── .auto.tfvars.example │ │ │ │ ├── backend.tf │ │ │ │ ├── main.tf │ │ │ │ ├── moved.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── providers.tf │ │ │ │ ├── vars-optional.tf │ │ │ │ ├── vars-required.tf │ │ │ │ └── versions.tf │ │ │ ├── step-4-breaking-the-terralith/ │ │ │ │ ├── catalog/ │ │ │ │ │ └── modules/ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── live/ │ │ │ │ ├── dev/ │ │ │ │ │ ├── .auto.tfvars.example │ │ │ │ │ ├── backend.tf │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── moved.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── providers.tf │ │ │ │ │ ├── removed.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── prod/ │ │ │ │ ├── .auto.tfvars.example │ │ │ │ ├── backend.tf │ │ │ │ ├── main.tf │ │ │ │ ├── moved.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── providers.tf │ │ │ │ ├── removed.tf │ │ │ │ ├── vars-optional.tf │ │ │ │ ├── vars-required.tf │ │ │ │ └── versions.tf │ │ │ ├── step-5-adding-terragrunt/ │ │ │ │ ├── catalog/ │ │ │ │ │ └── modules/ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── live/ │ │ │ │ ├── dev/ │ │ │ │ │ ├── moved.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── prod/ │ │ │ │ │ ├── moved.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ ├── step-6-breaking-the-terralith-further/ │ │ │ │ ├── catalog/ │ │ │ │ │ └── modules/ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── live/ │ │ │ │ ├── dev/ │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── s3/ │ │ │ │ │ ├── moved.tf │ │ │ │ │ ├── removed.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── prod/ │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── moved.tf │ │ │ │ │ │ ├── removed.tf │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── s3/ │ │ │ │ │ ├── moved.tf │ │ │ │ │ ├── removed.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ ├── step-7-taking-advantage-of-terragrunt-stacks/ │ │ │ │ ├── catalog/ │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ │ └── versions.tf │ │ │ │ │ │ ├── iam/ │ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ │ └── versions.tf │ │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ │ └── versions.tf │ │ │ │ │ │ └── s3/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── units/ │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── iam/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── s3/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── live/ │ │ │ │ ├── dev/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ ├── prod/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── root.hcl │ │ │ └── step-8-refactoring-state-with-terragrunt-stacks/ │ │ │ ├── catalog/ │ │ │ │ ├── modules/ │ │ │ │ │ ├── best_cat/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ └── vars-required.tf │ │ │ │ │ ├── ddb/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── iam/ │ │ │ │ │ │ ├── data.tf │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ ├── lambda/ │ │ │ │ │ │ ├── main.tf │ │ │ │ │ │ ├── outputs.tf │ │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ │ └── versions.tf │ │ │ │ │ └── s3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ ├── outputs.tf │ │ │ │ │ ├── vars-optional.tf │ │ │ │ │ ├── vars-required.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── units/ │ │ │ │ ├── ddb/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── iam/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── lambda/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── s3/ │ │ │ │ └── terragrunt.hcl │ │ │ └── live/ │ │ │ ├── dev/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── prod/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── root.hcl │ │ ├── layouts/ │ │ │ └── BaseLayout.astro │ │ ├── lib/ │ │ │ ├── commands/ │ │ │ │ ├── headings/ │ │ │ │ │ └── index.ts │ │ │ │ └── sidebar/ │ │ │ │ └── index.ts │ │ │ ├── github.ts │ │ │ └── utils.ts │ │ ├── pages/ │ │ │ ├── api/ │ │ │ │ └── v1/ │ │ │ │ └── compatibility/ │ │ │ │ └── [tool].ts │ │ │ ├── index.astro │ │ │ └── reference/ │ │ │ └── cli/ │ │ │ └── commands/ │ │ │ └── [...slug].astro │ │ └── styles/ │ │ ├── global.css │ │ ├── lists.css │ │ ├── starlight-right-sidebar.css │ │ └── starlight-search.css │ ├── tailwind.config.mjs │ ├── tests/ │ │ └── install_test.sh │ ├── tsconfig.json │ └── vercel.json ├── go.mod ├── go.sum ├── internal/ │ ├── awshelper/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── policy.go │ │ └── policy_test.go │ ├── cache/ │ │ ├── cache.go │ │ ├── cache_test.go │ │ └── context.go │ ├── cas/ │ │ ├── .gitignore │ │ ├── benchmark_test.go │ │ ├── cas.go │ │ ├── cas_test.go │ │ ├── content.go │ │ ├── content_test.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── getter.go │ │ ├── getter_ssh_test.go │ │ ├── getter_test.go │ │ ├── integration_test.go │ │ ├── local.go │ │ ├── race_test.go │ │ ├── store.go │ │ ├── store_test.go │ │ ├── tree.go │ │ └── tree_test.go │ ├── cli/ │ │ ├── app.go │ │ ├── app_test.go │ │ ├── commands/ │ │ │ ├── aws-provider-patch/ │ │ │ │ ├── aws-provider-patch.go │ │ │ │ ├── aws-provider-patch_test.go │ │ │ │ ├── cli.go │ │ │ │ ├── errors.go │ │ │ │ └── tofu_extensions_test.go │ │ │ ├── backend/ │ │ │ │ ├── bootstrap/ │ │ │ │ │ ├── bootstrap.go │ │ │ │ │ └── cli.go │ │ │ │ ├── cli.go │ │ │ │ ├── delete/ │ │ │ │ │ ├── cli.go │ │ │ │ │ └── delete.go │ │ │ │ └── migrate/ │ │ │ │ ├── cli.go │ │ │ │ └── migrate.go │ │ │ ├── catalog/ │ │ │ │ ├── TESTING.md │ │ │ │ ├── catalog.go │ │ │ │ ├── catalog_test.go │ │ │ │ ├── cli.go │ │ │ │ └── tui/ │ │ │ │ ├── command/ │ │ │ │ │ └── scaffold.go │ │ │ │ ├── components/ │ │ │ │ │ └── buttonbar/ │ │ │ │ │ └── buttonbar.go │ │ │ │ ├── delegate.go │ │ │ │ ├── keys.go │ │ │ │ ├── model.go │ │ │ │ ├── model_test.go │ │ │ │ ├── testdata/ │ │ │ │ │ └── TestTUIInitialOutput.golden │ │ │ │ ├── tui.go │ │ │ │ ├── update.go │ │ │ │ └── view.go │ │ │ ├── commands.go │ │ │ ├── dag/ │ │ │ │ ├── cli.go │ │ │ │ └── graph/ │ │ │ │ ├── cli.go │ │ │ │ └── cli_test.go │ │ │ ├── exec/ │ │ │ │ ├── cli.go │ │ │ │ ├── exec.go │ │ │ │ └── options.go │ │ │ ├── find/ │ │ │ │ ├── cli.go │ │ │ │ ├── find.go │ │ │ │ ├── find_test.go │ │ │ │ └── options.go │ │ │ ├── hcl/ │ │ │ │ ├── cli.go │ │ │ │ ├── format/ │ │ │ │ │ ├── cli.go │ │ │ │ │ ├── errors.go │ │ │ │ │ ├── format.go │ │ │ │ │ ├── format_bench_test.go │ │ │ │ │ ├── format_test.go │ │ │ │ │ └── testdata/ │ │ │ │ │ └── fixtures/ │ │ │ │ │ ├── a/ │ │ │ │ │ │ ├── b/ │ │ │ │ │ │ │ └── c/ │ │ │ │ │ │ │ ├── d/ │ │ │ │ │ │ │ │ ├── e/ │ │ │ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ │ │ │ └── services.hcl │ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── expected.hcl │ │ │ │ │ ├── ignored/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── .history/ │ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ │ └── .terragrunt-cache/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── validate/ │ │ │ │ ├── cli.go │ │ │ │ ├── validate.go │ │ │ │ └── validate_test.go │ │ │ ├── help/ │ │ │ │ └── cli.go │ │ │ ├── info/ │ │ │ │ ├── cli.go │ │ │ │ ├── print/ │ │ │ │ │ ├── cli.go │ │ │ │ │ └── print.go │ │ │ │ └── strict/ │ │ │ │ └── command.go │ │ │ ├── list/ │ │ │ │ ├── cli.go │ │ │ │ ├── list.go │ │ │ │ ├── list_test.go │ │ │ │ └── options.go │ │ │ ├── render/ │ │ │ │ ├── cli.go │ │ │ │ ├── options.go │ │ │ │ ├── render.go │ │ │ │ └── render_test.go │ │ │ ├── run/ │ │ │ │ ├── cli.go │ │ │ │ ├── flags.go │ │ │ │ ├── help.go │ │ │ │ └── run.go │ │ │ ├── scaffold/ │ │ │ │ ├── cli.go │ │ │ │ ├── scaffold.go │ │ │ │ └── scaffold_test.go │ │ │ ├── shortcuts.go │ │ │ ├── stack/ │ │ │ │ ├── cli.go │ │ │ │ ├── output.go │ │ │ │ ├── output_test.go │ │ │ │ └── stack.go │ │ │ └── version/ │ │ │ └── cli.go │ │ ├── flags/ │ │ │ ├── deprecated_flag.go │ │ │ ├── error_handler.go │ │ │ ├── error_handler_test.go │ │ │ ├── errors.go │ │ │ ├── flag.go │ │ │ ├── flag_opts.go │ │ │ ├── flag_test.go │ │ │ ├── global/ │ │ │ │ └── flags.go │ │ │ ├── prefix.go │ │ │ └── shared/ │ │ │ ├── all.go │ │ │ ├── auth.go │ │ │ ├── backend.go │ │ │ ├── config.go │ │ │ ├── doc.go │ │ │ ├── download.go │ │ │ ├── errors.go │ │ │ ├── failfast.go │ │ │ ├── feature.go │ │ │ ├── filter.go │ │ │ ├── graph.go │ │ │ ├── iamassumerole.go │ │ │ ├── inputsdebug.go │ │ │ ├── parallelism.go │ │ │ ├── queue.go │ │ │ ├── scaffold.go │ │ │ └── tfpath.go │ │ ├── help.go │ │ └── help_test.go │ ├── clihelper/ │ │ ├── app.go │ │ ├── args.go │ │ ├── args_test.go │ │ ├── autocomplete.go │ │ ├── bool_flag.go │ │ ├── bool_flag_test.go │ │ ├── category.go │ │ ├── command.go │ │ ├── command_test.go │ │ ├── commands.go │ │ ├── context.go │ │ ├── errors.go │ │ ├── exit_code.go │ │ ├── flag.go │ │ ├── flag_test.go │ │ ├── flags.go │ │ ├── flags_test.go │ │ ├── funcs.go │ │ ├── generic_flag.go │ │ ├── generic_flag_test.go │ │ ├── help.go │ │ ├── map_flag.go │ │ ├── map_flag_test.go │ │ ├── slice_flag.go │ │ ├── slice_flag_test.go │ │ ├── sort.go │ │ └── sort_test.go │ ├── cloner/ │ │ ├── clone.go │ │ └── cloner.go │ ├── codegen/ │ │ ├── codegen.go │ │ ├── errors.go │ │ ├── generate.go │ │ └── generate_test.go │ ├── component/ │ │ ├── component.go │ │ ├── component_test.go │ │ ├── stack.go │ │ ├── unit.go │ │ └── unit_output.go │ ├── configbridge/ │ │ └── bridge.go │ ├── ctyhelper/ │ │ ├── helper.go │ │ └── helper_test.go │ ├── discovery/ │ │ ├── benchmark_test.go │ │ ├── constructor.go │ │ ├── discovery.go │ │ ├── discovery_integration_test.go │ │ ├── discovery_test.go │ │ ├── doc.go │ │ ├── errors.go │ │ ├── filter_test.go │ │ ├── graph_option.go │ │ ├── graph_target_test.go │ │ ├── helpers.go │ │ ├── options.go │ │ ├── phase_filesystem.go │ │ ├── phase_graph.go │ │ ├── phase_parse.go │ │ ├── phase_relationship.go │ │ ├── phase_test.go │ │ ├── phase_worktree.go │ │ ├── phase_worktree_integration_test.go │ │ ├── phase_worktree_test.go │ │ └── types.go │ ├── engine/ │ │ ├── engine.go │ │ ├── engine_test.go │ │ ├── public_keys.go │ │ ├── types.go │ │ └── verification.go │ ├── errorconfig/ │ │ ├── types.go │ │ └── types_test.go │ ├── errors/ │ │ ├── errors.go │ │ ├── export.go │ │ ├── multierror.go │ │ └── util.go │ ├── experiment/ │ │ ├── errors.go │ │ ├── experiment.go │ │ ├── experiment_test.go │ │ └── warnings.go │ ├── filter/ │ │ ├── ast.go │ │ ├── ast_test.go │ │ ├── candidacy.go │ │ ├── candidacy_test.go │ │ ├── classifier.go │ │ ├── classifier_test.go │ │ ├── complex_test.go │ │ ├── diagnostic.go │ │ ├── diagnostic_test.go │ │ ├── doc.go │ │ ├── errors.go │ │ ├── evaluator.go │ │ ├── evaluator_test.go │ │ ├── examples_test.go │ │ ├── filter.go │ │ ├── filter_test.go │ │ ├── filters.go │ │ ├── filters_test.go │ │ ├── fuzz_test.go │ │ ├── hints.go │ │ ├── hints_test.go │ │ ├── lexer.go │ │ ├── lexer_test.go │ │ ├── matcher.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── telemetry.go │ │ ├── telemetry_test.go │ │ ├── token.go │ │ └── walk.go │ ├── gcphelper/ │ │ ├── config.go │ │ └── config_test.go │ ├── git/ │ │ ├── benchmark_test.go │ │ ├── diff.go │ │ ├── errors.go │ │ ├── git.go │ │ ├── git_test.go │ │ ├── gogit.go │ │ ├── gogit_test.go │ │ └── tree.go │ ├── github/ │ │ ├── client.go │ │ └── client_test.go │ ├── hclhelper/ │ │ ├── wrap.go │ │ └── wrap_test.go │ ├── iacargs/ │ │ ├── boolean_args_test.go │ │ ├── iacargs.go │ │ └── iacargs_test.go │ ├── iam/ │ │ └── iam.go │ ├── locks/ │ │ └── lock.go │ ├── os/ │ │ ├── exec/ │ │ │ ├── cmd.go │ │ │ ├── cmd_unix_test.go │ │ │ ├── cmd_windows_test.go │ │ │ ├── console_windows_test.go │ │ │ ├── opts.go │ │ │ ├── ptty_unix.go │ │ │ ├── ptty_windows.go │ │ │ └── testdata/ │ │ │ ├── infinite_loop.bat │ │ │ ├── test_exit_code.bat │ │ │ ├── test_exit_code.sh │ │ │ ├── test_graceful_shutdown.sh │ │ │ ├── test_sigint_multiple.sh │ │ │ └── test_sigint_wait.sh │ │ ├── signal/ │ │ │ ├── context_canceled.go │ │ │ ├── signal.go │ │ │ ├── signal_unix.go │ │ │ └── signal_windows.go │ │ └── stdout/ │ │ └── stdout.go │ ├── prepare/ │ │ └── prepare.go │ ├── providercache/ │ │ ├── options/ │ │ │ └── options.go │ │ ├── providercache.go │ │ ├── providercache_test.go │ │ └── resolve_modules_url_test.go │ ├── queue/ │ │ ├── queue.go │ │ └── queue_test.go │ ├── remotestate/ │ │ ├── backend/ │ │ │ ├── backend.go │ │ │ ├── common.go │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── errors.go │ │ │ ├── gcs/ │ │ │ │ ├── backend.go │ │ │ │ ├── backend_test.go │ │ │ │ ├── client.go │ │ │ │ ├── config.go │ │ │ │ ├── config_test.go │ │ │ │ ├── errors.go │ │ │ │ └── remote_state_config.go │ │ │ ├── normalize.go │ │ │ ├── normalize_test.go │ │ │ └── s3/ │ │ │ ├── backend.go │ │ │ ├── backend_test.go │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── counting_semaphore.go │ │ │ ├── counting_semaphore_test.go │ │ │ ├── errors.go │ │ │ ├── remote_state_config.go │ │ │ ├── remote_state_config_test.go │ │ │ └── retryer.go │ │ ├── config.go │ │ ├── remote_state.go │ │ ├── remote_state_test.go │ │ ├── terraform_state_file.go │ │ └── terraform_state_file_test.go │ ├── report/ │ │ ├── colors.go │ │ ├── report.go │ │ ├── report_test.go │ │ ├── summary.go │ │ └── writer.go │ ├── retry/ │ │ └── defaults.go │ ├── runner/ │ │ ├── common/ │ │ │ ├── options.go │ │ │ ├── runner.go │ │ │ └── unit_runner.go │ │ ├── graph/ │ │ │ └── graph.go │ │ ├── run/ │ │ │ ├── context.go │ │ │ ├── creds/ │ │ │ │ ├── getter.go │ │ │ │ └── providers/ │ │ │ │ ├── amazonsts/ │ │ │ │ │ └── provider.go │ │ │ │ ├── externalcmd/ │ │ │ │ │ ├── provider.go │ │ │ │ │ ├── schema.go │ │ │ │ │ └── schema_test.go │ │ │ │ └── provider.go │ │ │ ├── debug.go │ │ │ ├── download_source.go │ │ │ ├── download_source_test.go │ │ │ ├── errors.go │ │ │ ├── file_copy_getter.go │ │ │ ├── hook.go │ │ │ ├── hook_internal_test.go │ │ │ ├── options.go │ │ │ ├── prepare_internal_test.go │ │ │ ├── run.go │ │ │ ├── run_test.go │ │ │ ├── symlink_preserving_git_getter.go │ │ │ ├── tofu_extensions_test.go │ │ │ ├── version_check.go │ │ │ ├── version_check_internal_test.go │ │ │ └── version_check_test.go │ │ ├── runall/ │ │ │ ├── errors.go │ │ │ ├── runall.go │ │ │ └── runall_test.go │ │ ├── runcfg/ │ │ │ ├── types.go │ │ │ ├── util.go │ │ │ └── util_test.go │ │ ├── runner.go │ │ └── runnerpool/ │ │ ├── builder.go │ │ ├── builder_helpers.go │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── errors.go │ │ ├── graph_fallback_test.go │ │ ├── helpers_test.go │ │ ├── runner.go │ │ ├── runner_test.go │ │ ├── writer.go │ │ └── writer_test.go │ ├── services/ │ │ └── catalog/ │ │ ├── catalog.go │ │ ├── catalog_test.go │ │ └── module/ │ │ ├── doc.go │ │ ├── doc_test.go │ │ ├── module.go │ │ ├── module_test.go │ │ ├── repo.go │ │ ├── repo_test.go │ │ └── testdata/ │ │ └── find_modules/ │ │ ├── gitdir/ │ │ │ ├── HEAD │ │ │ └── config │ │ └── modules/ │ │ ├── eks-alb-ingress-controller/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ └── variables.tf │ │ ├── eks-alb-ingress-controller-iam-policy/ │ │ │ ├── README.md │ │ │ ├── main.tf │ │ │ └── variables.tf │ │ └── eks-aws-auth-merger/ │ │ ├── README.adoc │ │ ├── core-concepts.md │ │ ├── main.tf │ │ └── variables.tf │ ├── shell/ │ │ ├── error_explainer.go │ │ ├── error_explainer_test.go │ │ ├── git.go │ │ ├── prompt.go │ │ ├── run_cmd.go │ │ ├── run_cmd_output_test.go │ │ ├── run_cmd_test.go │ │ ├── run_cmd_unix_test.go │ │ ├── run_cmd_windows_test.go │ │ └── testdata/ │ │ ├── test_outputs.sh │ │ ├── test_sigint_wait.bat │ │ └── test_sigint_wait.sh │ ├── stacks/ │ │ ├── clean/ │ │ │ └── clean.go │ │ ├── generate/ │ │ │ └── generate.go │ │ └── output/ │ │ └── output.go │ ├── strict/ │ │ ├── category.go │ │ ├── control.go │ │ ├── control_test.go │ │ ├── controls/ │ │ │ ├── control.go │ │ │ ├── controls.go │ │ │ ├── deprecated_command.go │ │ │ ├── deprecated_env_var.go │ │ │ └── deprecated_flag_name.go │ │ ├── errors.go │ │ ├── status.go │ │ ├── strict.go │ │ └── view/ │ │ ├── plaintext/ │ │ │ ├── plaintext.go │ │ │ ├── render.go │ │ │ └── template.go │ │ ├── render.go │ │ ├── view.go │ │ └── writer.go │ ├── telemetry/ │ │ ├── context.go │ │ ├── errors.go │ │ ├── meter.go │ │ ├── meter_test.go │ │ ├── opts.go │ │ ├── telemeter.go │ │ ├── tracer.go │ │ ├── tracer_test.go │ │ └── util.go │ ├── tf/ │ │ ├── cache/ │ │ │ ├── config.go │ │ │ ├── controllers/ │ │ │ │ ├── discovery.go │ │ │ │ ├── downloader.go │ │ │ │ └── provider.go │ │ │ ├── handlers/ │ │ │ │ ├── common_provider.go │ │ │ │ ├── direct_provider.go │ │ │ │ ├── errors.go │ │ │ │ ├── filesystem_mirror_provider.go │ │ │ │ ├── network_mirror_provider.go │ │ │ │ ├── provider.go │ │ │ │ ├── provider_test.go │ │ │ │ ├── proxy_provider.go │ │ │ │ └── registry_urls.go │ │ │ ├── helpers/ │ │ │ │ ├── client.go │ │ │ │ ├── http.go │ │ │ │ └── reverse_proxy.go │ │ │ ├── middleware/ │ │ │ │ ├── key_auth.go │ │ │ │ ├── logger.go │ │ │ │ ├── package.go │ │ │ │ └── recover.go │ │ │ ├── models/ │ │ │ │ ├── helper.go │ │ │ │ ├── provider.go │ │ │ │ └── provider_test.go │ │ │ ├── router/ │ │ │ │ ├── controller.go │ │ │ │ └── router.go │ │ │ ├── server.go │ │ │ └── services/ │ │ │ ├── provider_cache.go │ │ │ └── service.go │ │ ├── cliconfig/ │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── credentials.go │ │ │ ├── provider_installation.go │ │ │ └── user_config.go │ │ ├── context.go │ │ ├── detailed_exitcode.go │ │ ├── doc.go │ │ ├── errors.go │ │ ├── getproviders/ │ │ │ ├── constraints.go │ │ │ ├── constraints_test.go │ │ │ ├── hash.go │ │ │ ├── hash_test.go │ │ │ ├── lock.go │ │ │ ├── lock_test.go │ │ │ ├── mocks/ │ │ │ │ ├── mock_lock.go │ │ │ │ └── mock_provider.go │ │ │ ├── package_authentication.go │ │ │ ├── package_authentication_test.go │ │ │ ├── provider.go │ │ │ ├── public_keys.go │ │ │ └── testdata/ │ │ │ └── filesystem-mirror/ │ │ │ └── tfe.example.com/ │ │ │ └── AwesomeCorp/ │ │ │ └── happycloud/ │ │ │ └── 0.1.0-alpha.2/ │ │ │ └── darwin_amd64/ │ │ │ ├── extra-data.txt │ │ │ └── terraform-provider-happycloud │ │ ├── getter.go │ │ ├── getter_test.go │ │ ├── log.go │ │ ├── run_cmd.go │ │ ├── run_cmd_test.go │ │ ├── source.go │ │ ├── source_test.go │ │ ├── testdata/ │ │ │ └── test_outputs.sh │ │ ├── tf.go │ │ └── tf_test.go │ ├── tfimpl/ │ │ └── tfimpl.go │ ├── tflint/ │ │ ├── README.md │ │ ├── tflint.go │ │ └── tflint_test.go │ ├── tips/ │ │ ├── errors.go │ │ ├── tip.go │ │ ├── tip_test.go │ │ └── tips.go │ ├── util/ │ │ ├── collections.go │ │ ├── collections_test.go │ │ ├── datetime.go │ │ ├── datetime_test.go │ │ ├── dirs.go │ │ ├── file.go │ │ ├── file_test.go │ │ ├── file_tofu_test.go │ │ ├── hash.go │ │ ├── jsons.go │ │ ├── jsons_test.go │ │ ├── lockfile.go │ │ ├── locks.go │ │ ├── locks_test.go │ │ ├── random.go │ │ ├── random_test.go │ │ ├── reflect.go │ │ ├── reflect_test.go │ │ ├── retry.go │ │ ├── shell.go │ │ ├── shell_test.go │ │ ├── sync_writer.go │ │ ├── testdata/ │ │ │ ├── fixture-glob-canonical/ │ │ │ │ ├── module-a/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── module-b/ │ │ │ │ ├── module-b-child/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ └── fixture-sanitize-path/ │ │ │ └── env/ │ │ │ └── unit/ │ │ │ └── .terraform-version │ │ ├── trap_writer.go │ │ ├── util.go │ │ └── writer_notifier.go │ ├── vfs/ │ │ ├── vfs.go │ │ └── vfs_test.go │ ├── view/ │ │ ├── diagnostic/ │ │ │ ├── diagnostic.go │ │ │ ├── expression_value.go │ │ │ ├── extra.go │ │ │ ├── function.go │ │ │ ├── range.go │ │ │ ├── servity.go │ │ │ └── snippet.go │ │ ├── human_render.go │ │ ├── json_render.go │ │ ├── view.go │ │ └── writer.go │ ├── worker/ │ │ ├── worker.go │ │ └── worker_test.go │ ├── worktrees/ │ │ ├── worktrees.go │ │ └── worktrees_test.go │ └── writer/ │ └── writer.go ├── main.go ├── mise.cicd.toml ├── mise.toml ├── pkg/ │ ├── config/ │ │ ├── cache_test.go │ │ ├── catalog.go │ │ ├── catalog_test.go │ │ ├── config.go │ │ ├── config_as_cty.go │ │ ├── config_as_cty_test.go │ │ ├── config_helpers.go │ │ ├── config_helpers_test.go │ │ ├── config_partial.go │ │ ├── config_partial_test.go │ │ ├── config_test.go │ │ ├── context.go │ │ ├── cty_helpers.go │ │ ├── dependency.go │ │ ├── dependency_inputs_test.go │ │ ├── dependency_test.go │ │ ├── engine.go │ │ ├── errors.go │ │ ├── errors_block.go │ │ ├── exclude.go │ │ ├── external_test.go │ │ ├── feature_flag.go │ │ ├── hclparse/ │ │ │ ├── attributes.go │ │ │ ├── block.go │ │ │ ├── errors.go │ │ │ ├── file.go │ │ │ ├── options.go │ │ │ └── parser.go │ │ ├── include.go │ │ ├── include_test.go │ │ ├── locals.go │ │ ├── locals_test.go │ │ ├── options.go │ │ ├── parsing_context.go │ │ ├── sops_race_test.go │ │ ├── sops_test.go │ │ ├── stack.go │ │ ├── stack_test.go │ │ ├── stack_validation.go │ │ ├── stack_validation_test.go │ │ ├── telemetry.go │ │ ├── translate.go │ │ ├── util.go │ │ ├── variable.go │ │ └── variable_test.go │ ├── log/ │ │ ├── context.go │ │ ├── context_test.go │ │ ├── external_test.go │ │ ├── fields.go │ │ ├── force_level_hook.go │ │ ├── force_level_hook_test.go │ │ ├── format/ │ │ │ ├── format.go │ │ │ ├── format_test.go │ │ │ ├── formatter.go │ │ │ ├── options/ │ │ │ │ ├── align.go │ │ │ │ ├── case.go │ │ │ │ ├── color.go │ │ │ │ ├── common.go │ │ │ │ ├── content.go │ │ │ │ ├── errors.go │ │ │ │ ├── escape.go │ │ │ │ ├── level_format.go │ │ │ │ ├── option.go │ │ │ │ ├── path_format.go │ │ │ │ ├── prefix.go │ │ │ │ ├── suffix.go │ │ │ │ ├── time_format.go │ │ │ │ ├── util.go │ │ │ │ └── width.go │ │ │ └── placeholders/ │ │ │ ├── common.go │ │ │ ├── errors.go │ │ │ ├── field.go │ │ │ ├── interval.go │ │ │ ├── level.go │ │ │ ├── message.go │ │ │ ├── placeholder.go │ │ │ ├── placeholder_test.go │ │ │ ├── plaintext.go │ │ │ └── time.go │ │ ├── formatter.go │ │ ├── level.go │ │ ├── level_test.go │ │ ├── log.go │ │ ├── logger.go │ │ ├── logger_test.go │ │ ├── options.go │ │ ├── util.go │ │ ├── util_test.go │ │ └── writer/ │ │ ├── options.go │ │ ├── writer.go │ │ └── writer_test.go │ ├── options/ │ │ ├── auto_retry_options.go │ │ ├── options.go │ │ └── options_test.go │ └── pkg.go └── test/ ├── benchmarks/ │ ├── .gitignore │ ├── helpers/ │ │ └── helpers.go │ ├── integration_auto_provider_cache_dir_bench_test.go │ ├── integration_bench_test.go │ └── integration_cas_bench_test.go ├── cliconfig.go ├── fixtures/ │ ├── assume-role/ │ │ ├── duration/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── external-id/ │ │ │ └── terragrunt.hcl │ │ └── external-id-with-comma/ │ │ └── terragrunt.hcl │ ├── assume-role-web-identity/ │ │ └── file-path/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── auth-provider-cmd/ │ │ ├── creds-for-dependency/ │ │ │ ├── dependency/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── creds.config │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── dependent/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── creds.config │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── mock-auth-cmd.sh │ │ ├── multiple-apps/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── creds.config │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── creds.config │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── creds.config │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── creds.config │ │ │ ├── root.hcl │ │ │ └── test-creds.sh │ │ ├── oidc/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── mock-auth-cmd.sh │ │ │ └── terragrunt.hcl │ │ ├── remote-state/ │ │ │ ├── creds.config │ │ │ └── terragrunt.hcl │ │ ├── remote-state-w-oidc/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── mock-auth-cmd.sh │ │ │ └── terragrunt.hcl │ │ └── sops/ │ │ ├── .terraform.lock.hcl │ │ ├── creds.config │ │ ├── main.tf │ │ ├── secrets.json │ │ └── terragrunt.hcl │ ├── auth-provider-parallel/ │ │ ├── auth-provider.sh │ │ ├── unit-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── unit-c/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── auto-provider-cache-dir/ │ │ ├── basic/ │ │ │ └── unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── heavy/ │ │ └── unit/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── aws-provider-patch/ │ │ ├── example-module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── broken-dependency/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── dependency/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── broken-locals/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── buffer-module-output/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── app2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── app3/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── catalog/ │ │ ├── complex/ │ │ │ ├── common.hcl │ │ │ ├── dev/ │ │ │ │ ├── account.hcl │ │ │ │ └── us-west-1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── modules/ │ │ │ │ │ └── terraform-aws-eks/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ ├── region.hcl │ │ │ │ └── terragrunt.hcl │ │ │ ├── prod/ │ │ │ │ ├── account.hcl │ │ │ │ └── terragrunt.hcl │ │ │ ├── root.hcl │ │ │ └── stage/ │ │ │ ├── account.hcl │ │ │ └── terragrunt.hcl │ │ ├── complex-legacy-root/ │ │ │ ├── common.hcl │ │ │ ├── dev/ │ │ │ │ ├── account.hcl │ │ │ │ └── us-west-1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── modules/ │ │ │ │ │ └── terraform-aws-eks/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ ├── region.hcl │ │ │ │ └── terragrunt.hcl │ │ │ ├── prod/ │ │ │ │ ├── account.hcl │ │ │ │ └── terragrunt.hcl │ │ │ ├── stage/ │ │ │ │ ├── account.hcl │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ ├── config1.hcl │ │ ├── config2.hcl │ │ ├── config3.hcl │ │ ├── config4.hcl │ │ ├── local-template/ │ │ │ ├── .boilerplate/ │ │ │ │ ├── boilerplate.yml │ │ │ │ ├── custom-template.txt │ │ │ │ └── terragrunt.hcl │ │ │ ├── app/ │ │ │ │ └── .gitkeep │ │ │ └── root.hcl │ │ └── terraform-aws-eks/ │ │ └── README.md │ ├── cli-flag-hints/ │ │ └── terragrunt.hcl │ ├── codegen/ │ │ ├── generate-attr/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── test.tf │ │ ├── generate-block/ │ │ │ ├── disable/ │ │ │ │ ├── .gitignore │ │ │ │ └── terragrunt.hcl │ │ │ ├── disable-signature/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── enable/ │ │ │ │ ├── .gitignore │ │ │ │ └── terragrunt.hcl │ │ │ ├── nested/ │ │ │ │ ├── .gitignore │ │ │ │ ├── child_inherit/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── backend.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── child_overwrite/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ ├── overwrite/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── backend.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── overwrite_terragrunt/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── backend.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── overwrite_terragrunt_error/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── backend.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── same_name_error/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── same_name_includes_error/ │ │ │ │ ├── app1.hcl │ │ │ │ ├── app2.hcl │ │ │ │ └── terragrunt.hcl │ │ │ ├── same_name_pair_error/ │ │ │ │ └── terragrunt.hcl │ │ │ └── skip/ │ │ │ └── terragrunt.hcl │ │ ├── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── remote-state/ │ │ │ ├── base/ │ │ │ │ ├── .gitignore │ │ │ │ └── terragrunt.hcl │ │ │ ├── error/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── overwrite/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── backend.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── s3/ │ │ │ │ ├── .gitignore │ │ │ │ └── terragrunt.hcl │ │ │ └── skip/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ └── terragrunt.hcl │ │ └── remove-file/ │ │ ├── remove/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── remove_terragrunt/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── remove_terragrunt_error/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── skip/ │ │ ├── .terraform.lock.hcl │ │ ├── backend.tf │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── commands-that-need-input/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── config-files/ │ │ ├── ignore-cached-config/ │ │ │ └── terragrunt.hcl │ │ ├── ignore-terraform-data-dir/ │ │ │ ├── .tf_data/ │ │ │ │ └── modules/ │ │ │ │ └── mod/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── root.hcl │ │ │ └── subdir/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tf_data/ │ │ │ │ └── modules/ │ │ │ │ └── mod/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── multiple-configs/ │ │ │ ├── subdir-1/ │ │ │ │ └── empty.txt │ │ │ ├── subdir-2/ │ │ │ │ └── subdir/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── subdir-3/ │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ ├── multiple-json-configs/ │ │ │ ├── subdir-1/ │ │ │ │ └── empty.txt │ │ │ ├── subdir-2/ │ │ │ │ └── subdir/ │ │ │ │ └── terragrunt.hcl.json │ │ │ ├── subdir-3/ │ │ │ │ └── terragrunt.hcl.json │ │ │ └── terragrunt.hcl.json │ │ ├── multiple-mixed-configs/ │ │ │ ├── subdir-1/ │ │ │ │ └── empty.txt │ │ │ ├── subdir-2/ │ │ │ │ └── subdir/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── subdir-3/ │ │ │ │ └── terragrunt.hcl.json │ │ │ └── terragrunt.hcl.json │ │ ├── none/ │ │ │ ├── empty.txt │ │ │ └── subdir/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── one-config/ │ │ │ ├── empty.txt │ │ │ └── subdir/ │ │ │ └── terragrunt.hcl │ │ ├── one-json-config/ │ │ │ ├── empty.txt │ │ │ └── subdir/ │ │ │ └── terragrunt.hcl.json │ │ ├── single-json-config/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl.json │ │ └── with-non-default-names/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.hcl │ │ │ └── main.tf │ │ ├── common.hcl │ │ ├── dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── another-name.hcl │ │ │ └── main.tf │ │ └── parent.hcl │ ├── config-terraform-functions/ │ │ ├── other-file.txt │ │ └── terragrunt.hcl │ ├── dag-graph/ │ │ ├── region-1/ │ │ │ └── unit-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── region-2/ │ │ │ └── unit-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl.hcl │ ├── dependency-optimisation/ │ │ ├── module-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-c/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── dependency-output/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── destroy-dependent-module/ │ │ ├── a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── c/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── destroy-dependent-module-errors/ │ │ ├── dev/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── env.hcl │ │ └── prod/ │ │ ├── app1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── app2/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── sops.yaml │ │ └── terragrunt.hcl │ ├── destroy-order/ │ │ ├── app/ │ │ │ ├── module-a/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-b/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-c/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-d/ │ │ │ │ └── terragrunt.hcl │ │ │ └── module-e/ │ │ │ └── terragrunt.hcl │ │ └── hello/ │ │ ├── .terraform.lock.hcl │ │ ├── hello/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── main.tf │ ├── destroy-warning/ │ │ ├── app-v1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── app-v2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ └── vpc/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── detailed-exitcode/ │ │ ├── changes/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── changes-with-source/ │ │ │ └── app1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── error/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── fail-on-first-run/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── fail-on-first-run-with-status/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── runall-retry-after-drift/ │ │ ├── app_drift/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── app_flaky/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── dirs/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── disabled/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-disabled/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-enabled/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── unit-without-enabled/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── disabled-path/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── docs/ │ │ ├── 01-quick-start/ │ │ │ ├── step-01/ │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-01.1/ │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-02/ │ │ │ │ ├── bar/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-03/ │ │ │ │ ├── bar/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── step-04/ │ │ │ │ ├── .gitignore │ │ │ │ ├── bar/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── step-05/ │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── bar/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── step-05.1/ │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── bar/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── step-06/ │ │ │ │ ├── .gitignore │ │ │ │ ├── bar/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── output.tf │ │ │ ├── step-07/ │ │ │ │ ├── .gitignore │ │ │ │ ├── bar/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── foo/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── shared/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── output.tf │ │ │ └── step-07.1/ │ │ │ ├── .gitignore │ │ │ ├── bar/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── foo/ │ │ │ │ └── terragrunt.hcl │ │ │ └── shared/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── output.tf │ │ ├── 02-overview/ │ │ │ ├── step-01-terragrunt.hcl/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-02-dependencies/ │ │ │ │ ├── ec2/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── root.hcl │ │ │ │ └── vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-03-mock-outputs/ │ │ │ │ ├── ec2/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── root.hcl │ │ │ │ └── vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── step-04-configuration-hierarchy/ │ │ │ │ ├── root.hcl │ │ │ │ └── us-east-1/ │ │ │ │ ├── ec2/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── region.hcl │ │ │ │ └── vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ └── step-05-exposed-includes/ │ │ │ ├── root.hcl │ │ │ ├── us-east-1/ │ │ │ │ ├── ec2/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── region.hcl │ │ │ │ └── vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ └── us-west-2/ │ │ │ ├── ec2/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── region.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ └── 03-stacks-with-local-state/ │ │ ├── .gitignore │ │ ├── live/ │ │ │ └── terragrunt.stack.hcl │ │ ├── root.hcl │ │ └── units/ │ │ └── basic/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── download/ │ │ ├── custom-lock-file-module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── custom-lock-file-terraform/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ ├── custom-lock-file-tofu/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ ├── extra-args/ │ │ │ └── common.tfvars │ │ ├── hello-world/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── hello/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── main.tf │ │ ├── hello-world-no-remote/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── hello-world-with-backend/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── init-on-source-change/ │ │ │ └── terragrunt.hcl │ │ ├── invalid-path/ │ │ │ └── terragrunt.hcl │ │ ├── local/ │ │ │ └── terragrunt.hcl │ │ ├── local-disable-copy-terraform-lock-file/ │ │ │ └── terragrunt.hcl │ │ ├── local-include-disable-copy-lock-file/ │ │ │ ├── module-a/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-b/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── local-include-with-prevent-destroy-dependencies/ │ │ │ ├── module-a/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-b/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-c/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── local-no-source/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── local-relative/ │ │ │ └── terragrunt.hcl │ │ ├── local-relative-extra-args-unix/ │ │ │ └── terragrunt.hcl │ │ ├── local-windows/ │ │ │ ├── JZwoL6Viko8bzuRvTOQFx3Jh8vs/ │ │ │ │ └── 3mU4huxMLOXOW5ZgJOFXGUFDKc8/ │ │ │ │ ├── hello/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── main.tf │ │ │ └── terragrunt.hcl │ │ ├── local-with-allowed-hidden/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.hcl │ │ │ └── modules/ │ │ │ ├── .nonce │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── local-with-backend/ │ │ │ └── terragrunt.hcl │ │ ├── local-with-exclude-dir/ │ │ │ ├── integration-env/ │ │ │ │ ├── aws/ │ │ │ │ │ └── module-aws-a/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── gce/ │ │ │ │ ├── module-gce-b/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── module-gce-c/ │ │ │ │ └── terragrunt.hcl │ │ │ └── production-env/ │ │ │ ├── aws/ │ │ │ │ └── module-aws-d/ │ │ │ │ └── terragrunt.hcl │ │ │ └── gce/ │ │ │ └── module-gce-e/ │ │ │ └── terragrunt.hcl │ │ ├── local-with-hidden-folder/ │ │ │ ├── .hidden-folder/ │ │ │ │ └── README.md │ │ │ └── terragrunt.hcl │ │ ├── local-with-include-dir/ │ │ │ ├── integration-env/ │ │ │ │ ├── aws/ │ │ │ │ │ └── module-aws-a/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── gce/ │ │ │ │ ├── module-gce-b/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── module-gce-c/ │ │ │ │ └── terragrunt.hcl │ │ │ └── production-env/ │ │ │ ├── aws/ │ │ │ │ └── module-aws-d/ │ │ │ │ └── terragrunt.hcl │ │ │ └── gce/ │ │ │ └── module-gce-e/ │ │ │ └── terragrunt.hcl │ │ ├── local-with-missing-backend/ │ │ │ └── terragrunt.hcl │ │ ├── local-with-prevent-destroy/ │ │ │ └── terragrunt.hcl │ │ ├── local-with-prevent-destroy-dependencies/ │ │ │ ├── module-a/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-b/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-c/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── module-d/ │ │ │ │ └── terragrunt.hcl │ │ │ └── module-e/ │ │ │ └── terragrunt.hcl │ │ ├── override/ │ │ │ └── terragrunt.hcl │ │ ├── relative/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── remote/ │ │ │ └── terragrunt.hcl │ │ ├── remote-invalid/ │ │ │ └── terragrunt.hcl │ │ ├── remote-invalid-with-retries/ │ │ │ └── terragrunt.hcl │ │ ├── remote-module-in-root/ │ │ │ └── terragrunt.hcl │ │ ├── remote-ref/ │ │ │ └── terragrunt.hcl │ │ ├── remote-relative/ │ │ │ └── terragrunt.hcl │ │ ├── remote-relative-with-slash/ │ │ │ └── terragrunt.hcl │ │ ├── remote-with-backend/ │ │ │ └── terragrunt.hcl │ │ ├── stdout/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── stdout-test/ │ │ ├── .terraform.lock.hcl │ │ └── terragrunt.hcl │ ├── download-source/ │ │ ├── download-dir-version-file/ │ │ │ └── version-file.txt │ │ ├── download-dir-version-file-local-hash/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── version-file.txt │ │ ├── download-dir-version-file-no-query/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── version-file.txt │ │ ├── download-dir-version-file-tf-code/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── version-file.txt │ │ ├── hello-world/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── hello-world-2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── version-file.txt │ │ ├── hello-world-local-hash/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── hello-world-local-hash-failed/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── hello-world-version-remote/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── version-file.txt │ ├── empty-state/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── endswith/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── engine/ │ │ ├── engine-dependencies/ │ │ │ ├── .gitignore │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── local-engine/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── opentofu-engine/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── opentofu-latest-run-all/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app4/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app5/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── opentofu-run-all/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app4/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app5/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── remote-engine/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── trace-parent/ │ │ ├── .terraform.lock.hcl │ │ ├── get_traceparent.sh │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── env-vars-block/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── ephemeral-inputs/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── error-print/ │ │ ├── .terraform.lock.hcl │ │ ├── custom-tf-script.sh │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── errors/ │ │ ├── default/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── get-default-errors/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── ignore/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── ignore-negative-pattern/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── ignore-signal/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── multi-line/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── script.sh │ │ │ └── terragrunt.hcl │ │ ├── no-auto-retry/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── retry/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── script.sh │ │ │ └── terragrunt.hcl │ │ ├── retry-fail/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── script.sh │ │ │ └── terragrunt.hcl │ │ ├── run-all/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── script.sh │ │ │ │ └── terragrunt.hcl │ │ │ └── common.hcl │ │ └── run-all-ignore/ │ │ ├── app1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── app2/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── exclude/ │ │ ├── basic/ │ │ │ ├── unit1/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── unit2/ │ │ │ │ └── terragrunt.hcl │ │ │ └── unit3/ │ │ │ └── terragrunt.hcl │ │ └── comprehensive/ │ │ ├── action-mismatch/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── always-excluded/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── conditional-flag/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── conditional-no-run/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dep-unit/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── exclude-all-except-output/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── exclude-apply-only/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── exclude-plan-only/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── flags.hcl │ │ ├── never-excluded/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-run-false/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-run-not-set/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-run-true/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── normal-unit/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── with-dep/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── exclude-by-default/ │ │ ├── _stacks/ │ │ │ └── terragrunt.stack.hcl │ │ └── unit1/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── excludes-file/ │ │ ├── .terragrunt-excludes │ │ ├── a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── c/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── d/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── excludes-file-pass-as-flag │ ├── exec-cmd/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── script.sh │ ├── exec-cmd-tf-path/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── script.sh │ │ ├── terraform-output-json.sh │ │ └── tofu-output-json.sh │ ├── external-dependencies/ │ │ ├── module-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── module-b/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── external-dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── extra-args/ │ │ ├── .terraform.lock.hcl │ │ ├── dev.tfvars │ │ ├── extra.tfvars │ │ ├── main.tf │ │ ├── terragrunt.hcl │ │ └── us-west-2.tfvars │ ├── fail-fast/ │ │ ├── unit-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── unit-c/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── fail-fast-early-exit/ │ │ ├── depends-on-failing/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── depends-on-succeeding/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── failing-unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── succeeding-unit/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── failure/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── missingvars/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── submod/ │ │ │ │ └── main.tf │ │ │ └── terragrunt.hcl │ │ ├── submod/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── feature-flags/ │ │ ├── error-empty-flag/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── include-flag/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── run-all/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── common.hcl │ │ └── simple-flag/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── filter/ │ │ ├── mark-as-read/ │ │ │ ├── unit-duplicate/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── unit-empty/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── unit-no-mark/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── unit-normal/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── minimize-parsing/ │ │ │ ├── dependency-unit/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── excluded-unit-1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── excluded-unit-2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── excluded-unit-3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── target-unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── minimize-parsing-destroy/ │ │ ├── landmine-unit-1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── landmine-unit-2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── unit-b/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── filter-source/ │ │ ├── github-acme-bar/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── github-acme-foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── gitlab-example-baz/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── local-module/ │ │ ├── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── find/ │ │ ├── basic/ │ │ │ ├── stack/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit/ │ │ │ └── terragrunt.hcl │ │ ├── dag/ │ │ │ ├── a-dependent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── b-dependency/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── c-mixed-deps/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── d-dependencies-only/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── hidden/ │ │ │ ├── .hide/ │ │ │ │ └── unit/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── stack/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit/ │ │ │ └── terragrunt.hcl │ │ ├── include/ │ │ │ ├── bar/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── cloud.hcl │ │ │ └── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── internal-v-external/ │ │ │ ├── external/ │ │ │ │ └── c-dependency/ │ │ │ │ └── terragrunt.hcl │ │ │ └── internal/ │ │ │ ├── a-dependent/ │ │ │ │ └── terragrunt.hcl │ │ │ └── b-dependency/ │ │ │ └── terragrunt.hcl │ │ └── read-terragrunt-config/ │ │ ├── .terraform.lock.hcl │ │ ├── common_deps.hcl │ │ ├── main.tf │ │ ├── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── find-parent/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── find-parent-with-deprecated-root/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── gcs/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── gcs-backend/ │ │ ├── common.hcl │ │ ├── unit1/ │ │ │ └── terragrunt.hcl │ │ └── unit2/ │ │ └── terragrunt.hcl │ ├── gcs-byo-bucket/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── gcs-impersonate/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── gcs-no-bucket/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── gcs-no-prefix/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── gcs-parallel-state-init/ │ │ ├── root.hcl │ │ └── template/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── get-aws-account-alias/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── get-aws-caller-identity/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── get-output/ │ │ ├── cycle/ │ │ │ ├── aa/ │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── aba/ │ │ │ │ ├── bar/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── abca/ │ │ │ │ ├── bar/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── baz/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── foo/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── abcda/ │ │ │ ├── bar/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── baz/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── car/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── download-dir/ │ │ │ ├── in-config/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── not-set/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── integration/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── empty/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── localstate/ │ │ │ ├── live/ │ │ │ │ ├── child/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── parent/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ └── modules/ │ │ │ ├── child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── parent/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── mock-outputs/ │ │ │ ├── dependent1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dependent2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dependent3/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── source/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── mock-outputs-merge-strategy-with-state/ │ │ │ ├── merge-strategy-with-state-compat-conflict/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-strategy-with-state-compat-false/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-strategy-with-state-compat-true/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-strategy-with-state-deep-map-only/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-strategy-with-state-default/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-strategy-with-state-no-merge/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── merge-strategy-with-state-shallow/ │ │ │ ├── live/ │ │ │ │ ├── child/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── parent/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ └── modules/ │ │ │ ├── child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── parent/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── mock-outputs-merge-with-state/ │ │ │ ├── merge-with-state-default/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-with-state-false/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-with-state-no-override/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── merge-with-state-true/ │ │ │ │ ├── live/ │ │ │ │ │ ├── child/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ ├── parent/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── root.hcl │ │ │ │ └── modules/ │ │ │ │ ├── child/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── parent/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── merge-with-state-true-validate-only/ │ │ │ ├── live/ │ │ │ │ ├── child/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── parent/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── root.hcl │ │ │ └── modules/ │ │ │ ├── child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── parent/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── nested-mocks/ │ │ │ ├── deepdep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── live/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── nested-optimization/ │ │ │ ├── .gitignore │ │ │ ├── deepdep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── live/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── nested-optimization-disable/ │ │ │ ├── .gitignore │ │ │ ├── deepdep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── live/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── nested-optimization-nogen/ │ │ │ ├── .gitignore │ │ │ ├── deepdep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── live/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── regression-1102/ │ │ │ ├── .gitignore │ │ │ ├── backend.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── regression-1124/ │ │ │ ├── live/ │ │ │ │ ├── app/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── dependency/ │ │ │ │ └── terragrunt.hcl │ │ │ └── modules/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── regression-1273/ │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── main/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── regression-854/ │ │ │ └── root/ │ │ │ ├── environments/ │ │ │ │ ├── network/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── web/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── sg/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ ├── regression-906/ │ │ │ ├── a/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── b/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── c/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── common-dep/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── d/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── e/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── f/ │ │ │ │ └── terragrunt.hcl │ │ │ └── g/ │ │ │ └── terragrunt.hcl │ │ ├── run-all-source/ │ │ │ ├── live/ │ │ │ │ ├── unit1/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── unit2/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── modules-default/ │ │ │ │ ├── module1/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── module2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── modules-marked/ │ │ │ ├── module1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── MODULE1_MARKER │ │ │ │ └── main.tf │ │ │ └── module2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── MODULE2_MARKER │ │ │ └── main.tf │ │ └── type-conversion/ │ │ └── terragrunt.hcl │ ├── get-path/ │ │ ├── get-path-from-repo-root/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── get-path-to-repo-root/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── path_relative_from_include/ │ │ ├── lives/ │ │ │ ├── dev/ │ │ │ │ ├── base/ │ │ │ │ │ ├── terragrunt.hcl │ │ │ │ │ └── tier.hcl │ │ │ │ ├── cluster/ │ │ │ │ │ ├── terragrunt.hcl │ │ │ │ │ └── tier.hcl │ │ │ │ └── env.hcl │ │ │ ├── org.hcl │ │ │ └── root.hcl │ │ └── modules/ │ │ ├── base/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── cluster/ │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── get-platform/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── get-repo-root/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── get-terragrunt-source-cli/ │ │ ├── terraform_config_cli/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── get-terragrunt-source-hcl/ │ │ ├── terraform_config_hcl/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── get-working-dir/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── modules/ │ │ │ └── a/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── graph/ │ │ ├── eks/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── lambda/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── services/ │ │ ├── eks-service-1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-2-v2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-3/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-3-v2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-3-v3/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-4/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── eks-service-5/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── lambda-service-1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── lambda-service-2/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── graph-dependencies/ │ │ ├── root/ │ │ │ ├── backend-app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── frontend-app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── mysql/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── redis/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── hcl-filter/ │ │ ├── fmt/ │ │ │ ├── already-formatted/ │ │ │ │ ├── app1/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── app2/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── needs-formatting/ │ │ │ │ ├── db/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── nested/ │ │ │ │ ├── api/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── deep/ │ │ │ │ └── web/ │ │ │ │ └── terragrunt.hcl │ │ │ └── stacks/ │ │ │ ├── already-formatted/ │ │ │ │ └── stack2/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── needs-formatting/ │ │ │ └── stack1/ │ │ │ └── terragrunt.stack.hcl │ │ └── validate/ │ │ ├── semantic-error/ │ │ │ ├── incomplete-block/ │ │ │ │ └── terragrunt.hcl │ │ │ └── missing-value/ │ │ │ └── terragrunt.hcl │ │ ├── stacks/ │ │ │ ├── syntax-error/ │ │ │ │ └── stack2/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── valid/ │ │ │ └── stack1/ │ │ │ └── terragrunt.stack.hcl │ │ ├── syntax-error/ │ │ │ ├── invalid-char/ │ │ │ │ └── terragrunt.hcl │ │ │ └── invalid-key/ │ │ │ └── terragrunt.hcl │ │ └── valid/ │ │ ├── db/ │ │ │ └── terragrunt.hcl │ │ └── nested/ │ │ ├── api/ │ │ │ └── terragrunt.hcl │ │ └── deep/ │ │ └── web/ │ │ └── terragrunt.hcl │ ├── hclfmt-check/ │ │ ├── a/ │ │ │ ├── b/ │ │ │ │ └── c/ │ │ │ │ ├── d/ │ │ │ │ │ ├── e/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── services.hcl │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ ├── expected.hcl │ │ └── terragrunt.hcl │ ├── hclfmt-check-errors/ │ │ ├── a/ │ │ │ ├── b/ │ │ │ │ └── c/ │ │ │ │ ├── d/ │ │ │ │ │ ├── e/ │ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ │ └── services.hcl │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ ├── expected.hcl │ │ └── terragrunt.hcl │ ├── hclfmt-diff/ │ │ ├── expected.diff │ │ └── terragrunt.hcl │ ├── hclfmt-errors/ │ │ ├── dangling-attribute/ │ │ │ └── terragrunt.hcl │ │ ├── invalid-character/ │ │ │ └── terragrunt.hcl │ │ └── invalid-key/ │ │ └── terragrunt.hcl │ ├── hclfmt-heredoc/ │ │ ├── expected.hcl │ │ └── terragrunt.hcl │ ├── hclfmt-stdin/ │ │ ├── expected.hcl │ │ └── terragrunt.hcl │ ├── hclvalidate/ │ │ ├── first/ │ │ │ └── b/ │ │ │ └── terragrunt.hcl │ │ ├── second/ │ │ │ ├── a/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── c/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── d/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── valid/ │ │ ├── circular-reference/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── invalid-local/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── single-required-input/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── validation-block/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── var-in-source/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── var-in-version/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── hidden-runall/ │ │ └── .cloud/ │ │ └── terraform/ │ │ ├── app1/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── app2/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── hooks/ │ │ ├── after-only/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── all/ │ │ │ ├── after-only/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── before-only/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── bad-arg-action/ │ │ │ ├── empty-command-list/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── empty-string-command/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── before-after-and-error-merge/ │ │ │ ├── qa/ │ │ │ │ └── my-app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── before-after-and-on-error/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── before-and-after/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── hook.sh │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── before-only/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── error-hooks/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── tf.sh │ │ ├── error-hooks-source-download-fail/ │ │ │ └── terragrunt.hcl │ │ ├── exit-code-error/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── if-parameter/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── init-once/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ ├── base-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── no-source-no-backend/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── no-source-with-backend/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── with-source-no-backend/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── with-source-no-backend-suppress-hook-stdout/ │ │ │ │ └── terragrunt.hcl │ │ │ └── with-source-with-backend/ │ │ │ └── terragrunt.hcl │ │ ├── interpolations/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── one-arg-action/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── path-preservation/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── skip-on-error/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── working_dir/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── mydir/ │ │ │ └── hello_world │ │ └── terragrunt.hcl │ ├── include/ │ │ ├── qa/ │ │ │ └── my-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ └── stage/ │ │ └── my-app/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── include-deep/ │ │ ├── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ └── vpc/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── include-expose/ │ │ ├── mixed-with-bare/ │ │ │ └── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── multiple/ │ │ │ └── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ ├── single/ │ │ │ └── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── single-bare/ │ │ │ └── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── terragrunt_env.hcl │ │ ├── with-dependency/ │ │ │ ├── child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ └── with-dependency-reference-input/ │ │ ├── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── include-multiple/ │ │ ├── deep-merge-nonoverlapping/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── deep-merge-overlapping/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── expose/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── has-bare-include/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── json/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl.json │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── modules/ │ │ │ ├── empty/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── reflect/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── shallow-deep-merge-overlapping/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── shallow-merge/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── terragrunt_inputs.hcl │ │ ├── terragrunt_inputs_final.hcl │ │ ├── terragrunt_inputs_override.hcl │ │ ├── terragrunt_vpc_dep.hcl │ │ ├── terragrunt_vpc_dep_for_expose.hcl │ │ └── terragrunt_vpc_dep_override.hcl │ ├── include-parent/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── common.hcl │ │ ├── dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── parent.hcl │ ├── include-runall/ │ │ ├── a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── alpha.hcl │ │ ├── b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── c/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── init-cache/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── init-error/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── init-once/ │ │ ├── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── inputs/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── inputs-defaults/ │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── inputs-interpolation/ │ │ ├── main.tf │ │ ├── stuff.json │ │ └── terragrunt.hcl │ ├── list/ │ │ ├── basic/ │ │ │ ├── a-unit/ │ │ │ │ └── terragrunt.hcl │ │ │ └── b-unit/ │ │ │ └── terragrunt.hcl │ │ ├── dag/ │ │ │ ├── stacks/ │ │ │ │ └── live/ │ │ │ │ ├── dev/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── prod/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── live/ │ │ │ ├── dev/ │ │ │ │ ├── db/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── ec2/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ └── prod/ │ │ │ ├── db/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── ec2/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ └── long/ │ │ ├── unit-0/ │ │ │ └── terragrunt.hcl │ │ ├── unit-1/ │ │ │ └── terragrunt.hcl │ │ ├── unit-2/ │ │ │ └── terragrunt.hcl │ │ ├── unit-3/ │ │ │ └── terragrunt.hcl │ │ ├── unit-4/ │ │ │ └── terragrunt.hcl │ │ ├── unit-5/ │ │ │ └── terragrunt.hcl │ │ ├── unit-6/ │ │ │ └── terragrunt.hcl │ │ ├── unit-7/ │ │ │ └── terragrunt.hcl │ │ ├── unit-8/ │ │ │ └── terragrunt.hcl │ │ └── unit-9/ │ │ └── terragrunt.hcl │ ├── locals/ │ │ ├── canonical/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── contents.txt │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── local-in-include/ │ │ │ ├── qa/ │ │ │ │ └── my-app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── run-multiple/ │ │ │ └── terragrunt.hcl │ │ └── run-once/ │ │ └── terragrunt.hcl │ ├── locals-errors/ │ │ ├── undefined-local/ │ │ │ └── terragrunt.hcl │ │ └── undefined-local-but-input/ │ │ └── terragrunt.hcl │ ├── log/ │ │ ├── formatter/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── levels/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── rel-paths/ │ │ └── duplicate-dir-names/ │ │ └── workspace/ │ │ └── one/ │ │ └── two/ │ │ ├── aaa/ │ │ │ └── bbb/ │ │ │ └── ccc/ │ │ │ ├── module-b/ │ │ │ │ └── terragrunt.hcl │ │ │ └── workspace/ │ │ │ └── terragrunt.hcl │ │ └── tf/ │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── manifest/ │ │ ├── version-1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── stale.tf │ │ ├── version-2/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── version-3-subfolder/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── sub/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── version-4-subfolder-empty/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── version-5-not-empty-subfolder/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── sub2/ │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── manifest-removal/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── missing-dependencies/ │ │ ├── main/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── module-a/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── mixed-config/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── stack/ │ │ │ └── terragrunt.stack.hcl │ │ └── unit/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── module-path-in-error/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── d1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── provider.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── modules/ │ │ ├── hcl-module-b/ │ │ │ ├── module-b-child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl.json │ │ ├── hcl-module-c/ │ │ │ └── terragrunt.hcl │ │ ├── json-module-a/ │ │ │ └── terragrunt.hcl.json │ │ ├── json-module-b/ │ │ │ ├── module-b-child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl.json │ │ │ └── root.hcl │ │ ├── json-module-c/ │ │ │ └── terragrunt.hcl.json │ │ ├── json-module-d/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl.json │ │ ├── module-a/ │ │ │ └── terragrunt.hcl │ │ ├── module-abba/ │ │ │ └── terragrunt.hcl │ │ ├── module-b/ │ │ │ ├── module-b-child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── module-c/ │ │ │ └── terragrunt.hcl │ │ ├── module-d/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-e/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── module-e-child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── module-f/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-g/ │ │ │ └── terragrunt.hcl │ │ ├── module-h/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-i/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── terragrunt.hcl │ │ │ └── test.tf │ │ ├── module-j/ │ │ │ └── terragrunt.hcl │ │ ├── module-k/ │ │ │ └── terragrunt.hcl │ │ ├── module-l/ │ │ │ └── terragrunt.hcl │ │ ├── module-m/ │ │ │ ├── env.hcl │ │ │ ├── module-m-child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── terragrunt.hcl │ │ │ │ └── tier.hcl │ │ │ └── root.hcl │ │ └── module-missing-dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── multiinclude-dependency/ │ │ ├── depa/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── depa.hcl │ │ ├── depb/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── depb.hcl │ │ ├── depc/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── depc.hcl │ │ ├── main/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── no-color/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── no-color-dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── terragrunt.hcl │ │ └── y/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── no-submodules/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── null-values/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── out-dir/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── output-all/ │ │ ├── env1/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app3/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── output-from-dependency/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── terragrunt.hcl │ │ └── variables.tf │ ├── output-from-remote-state/ │ │ ├── env1/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── terragrunt.hcl │ │ │ │ └── variables.tf │ │ │ └── app3/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── output-module-groups/ │ │ └── root/ │ │ ├── backend-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── frontend-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── mysql/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── redis/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ └── vpc/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── parallel-run/ │ │ ├── .tflint.hcl │ │ ├── common/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ ├── dev/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── parallel-state-init/ │ │ ├── root.hcl │ │ └── template/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── parallelism/ │ │ ├── template/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── parent-folders/ │ │ ├── in-another-subfolder/ │ │ │ ├── common/ │ │ │ │ └── foo.txt │ │ │ └── live/ │ │ │ └── terragrunt.hcl │ │ ├── multiple-terragrunt-in-parents/ │ │ │ ├── child/ │ │ │ │ ├── root.hcl │ │ │ │ └── sub-child/ │ │ │ │ ├── root.hcl │ │ │ │ └── sub-sub-child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── no-terragrunt-in-root/ │ │ │ └── child/ │ │ │ └── sub-child/ │ │ │ └── terragrunt.hcl │ │ ├── other-file-names/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── foo.txt │ │ ├── terragrunt-in-root/ │ │ │ ├── child/ │ │ │ │ └── sub-child/ │ │ │ │ └── sub-sub-child/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── override/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ └── with-params/ │ │ └── tfwork/ │ │ ├── test-var/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── providers.tf │ │ └── tg/ │ │ └── terragrunt.hcl │ ├── parsing/ │ │ └── exposed-include-with-deprecated-inputs/ │ │ ├── child/ │ │ │ └── terragrunt.hcl │ │ ├── compcommon.hcl │ │ ├── dep/ │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── partial-parse/ │ │ ├── ignore-bad-block-in-parent/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── partial-inheritance/ │ │ │ ├── child/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ └── terragrunt-version-constraint/ │ │ └── terragrunt.hcl │ ├── planfile-order-test/ │ │ ├── .gitignore │ │ ├── .terraform.lock.hcl │ │ ├── inputs.tf │ │ ├── resource.tf │ │ ├── terragrunt.hcl │ │ └── vars/ │ │ └── variables.tfvars │ ├── prevent-destroy-not-set/ │ │ ├── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── prevent-destroy-override/ │ │ ├── child/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── private-registry/ │ │ ├── env.tfrc │ │ └── terragrunt.hcl │ ├── provider-cache/ │ │ ├── dependency/ │ │ │ ├── .gitignore │ │ │ ├── app/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── dep/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── direct/ │ │ │ ├── .gitignore │ │ │ ├── first/ │ │ │ │ ├── app/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app2/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app3/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app4/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app5/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app6/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app7/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── app8/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── app9/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── second/ │ │ │ ├── app/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app1/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app3/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app4/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app5/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app6/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app7/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app8/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app9/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── filesystem-mirror/ │ │ │ └── app/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── multiple-platforms/ │ │ │ ├── .gitignore │ │ │ ├── app1/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── app2/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app3/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── network-mirror/ │ │ │ └── apps/ │ │ │ ├── app0/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app1/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── weak-constraint/ │ │ ├── .gitignore │ │ └── app/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── queue-strict-include/ │ │ ├── dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dependent/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── transitive-dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── queue-strict-include-units-reading/ │ │ ├── live/ │ │ │ └── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── sources/ │ │ └── source.hcl │ ├── read-config/ │ │ ├── from_dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── terragrunt.hcl │ │ │ │ └── vars.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── full/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── source.hcl │ │ │ └── terragrunt.hcl │ │ ├── iam_role_in_file/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── iam_roles_multiple_modules/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── component1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── component2/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── with_constraints/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── with_default/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── with_dependency/ │ │ │ ├── dep/ │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ └── with_original_terragrunt_dir/ │ │ ├── .terraform.lock.hcl │ │ ├── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── foo/ │ │ │ └── bar.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── read-tf-vars/ │ │ ├── empty.tfvars │ │ ├── my.tfvars │ │ ├── my.tfvars.json │ │ ├── only-comments.tfvars │ │ └── terragrunt.hcl │ ├── regressions/ │ │ ├── 5195-scope-escape/ │ │ │ ├── bastion/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── module1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── module2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── accesslogging-bucket/ │ │ │ ├── no-target-prefix-input/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── remote_terragrunt.hcl │ │ │ └── with-target-prefix-input/ │ │ │ ├── .gitignore │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── remote_terragrunt.hcl │ │ ├── apply-all-envvar/ │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── no-require-envvar/ │ │ │ │ └── terragrunt.hcl │ │ │ └── require-envvar/ │ │ │ └── terragrunt.hcl │ │ ├── benchmark-parsing/ │ │ │ ├── modules/ │ │ │ │ └── dummy-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── variables.tf │ │ │ │ └── versions.tf │ │ │ ├── production/ │ │ │ │ ├── dependency-group-template/ │ │ │ │ │ ├── app.hcl │ │ │ │ │ └── webserver/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── deployment-group-1/ │ │ │ │ │ ├── app.hcl │ │ │ │ │ └── webserver/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── environment.hcl │ │ │ └── root-terragrunt.hcl │ │ ├── benchmark-parsing-includes/ │ │ │ ├── modules/ │ │ │ │ └── dummy-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── variables.tf │ │ │ │ └── versions.tf │ │ │ ├── production/ │ │ │ │ ├── dependency-group-template/ │ │ │ │ │ ├── app.hcl │ │ │ │ │ └── webserver/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ ├── deployment-group-1/ │ │ │ │ │ ├── app.hcl │ │ │ │ │ └── webserver/ │ │ │ │ │ └── terragrunt.hcl │ │ │ │ └── environment.hcl │ │ │ └── root-terragrunt.hcl │ │ ├── dependency-empty-config-path/ │ │ │ ├── _source/ │ │ │ │ └── units/ │ │ │ │ └── consumer/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── live/ │ │ │ └── terragrunt.stack.hcl │ │ ├── dependency-generate/ │ │ │ ├── modules/ │ │ │ │ ├── other-module/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── test-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── other/ │ │ │ │ └── terragrunt.hcl │ │ │ └── testing/ │ │ │ └── terragrunt.hcl │ │ ├── dependency-include-error/ │ │ │ ├── dep/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── layer.hcl │ │ │ ├── root.hcl │ │ │ └── unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── disabled-dependency-empty-config-path/ │ │ │ ├── modules/ │ │ │ │ └── id/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── root.hcl │ │ │ ├── unit-a/ │ │ │ │ └── terragrunt.hcl │ │ │ └── unit-b/ │ │ │ └── terragrunt.hcl │ │ ├── exclude-dependency/ │ │ │ ├── amazing-app/ │ │ │ │ └── k8s/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── clusters/ │ │ │ │ └── eks/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── modules/ │ │ │ │ ├── eks/ │ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ │ └── main.tf │ │ │ │ └── k8s/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── root.hcl │ │ │ └── testapp/ │ │ │ └── k8s/ │ │ │ └── terragrunt.hcl │ │ ├── include-error/ │ │ │ ├── _envcommon.hcl │ │ │ └── project/ │ │ │ ├── app/ │ │ │ │ └── terragrunt.hcl │ │ │ └── eng_teams.hcl │ │ ├── mocks-merge-with-state/ │ │ │ ├── deep-map/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── shallow/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── multiple-dependency-load-sync/ │ │ │ ├── dep1/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── dep2/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── main/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── modules/ │ │ │ │ └── dummy-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── variables.tf │ │ │ │ └── versions.tf │ │ │ └── root-terragrunt.hcl │ │ ├── multiple-stacks/ │ │ │ ├── live/ │ │ │ │ ├── appv2.terragrunt.stack.hcl │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── template/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── not-existing-dependency/ │ │ │ ├── invalid-path/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── parent-find-fail/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── parsing-run-all-with-generate/ │ │ │ ├── root.hcl │ │ │ ├── services/ │ │ │ │ └── test1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── services-info/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── run-cmd-include-output/ │ │ │ ├── root.hcl │ │ │ ├── scripts/ │ │ │ │ └── emit_output.sh │ │ │ ├── unit-a/ │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── unit-b/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── sensitive-values/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── dev.enc.yaml │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── skip-init/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── terragrunt.hcl │ │ ├── skip-versioning/ │ │ │ ├── .gitignore │ │ │ ├── .terraform.lock.hcl │ │ │ ├── local_terragrunt.hcl │ │ │ ├── main.tf │ │ │ └── remote_terragrunt.hcl │ │ └── yamldecode/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── relative-include-cmd/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── app.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt-test.hcl │ ├── render-json/ │ │ ├── common_vars.hcl │ │ ├── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── main/ │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── render-json-inputs/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── render-json-metadata/ │ │ ├── attributes/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dependencies/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── include.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dependency1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── dependency2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dependency/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── dependency/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── dependency2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── includes/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── generate.hcl │ │ │ │ ├── inputs.hcl │ │ │ │ ├── locals.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── common/ │ │ │ └── common.hcl │ │ └── terraform-remote-state/ │ │ ├── app/ │ │ │ └── terragrunt.hcl │ │ ├── common/ │ │ │ ├── remote_state.hcl │ │ │ └── terraform.hcl │ │ └── terraform/ │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── render-json-mock-outputs/ │ │ ├── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── render-json-regression/ │ │ ├── bar/ │ │ │ └── terragrunt.hcl │ │ ├── baz/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── terragrunt.hcl │ ├── render-json-with-encryption/ │ │ ├── common_vars.hcl │ │ ├── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── main/ │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── report/ │ │ ├── chain-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── chain-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── chain-c/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── error-ignore/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── first-early-exit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── first-exclude/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── first-failure/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── first-success/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── retry-success/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── second-early-exit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── second-exclude/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── second-failure/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── second-success/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── root-terragrunt-hcl-regression/ │ │ ├── bar/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── baz/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── run-cmd-flags/ │ │ ├── module-conflict/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-global-cache-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-global-cache-b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-no-cache/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-quiet/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── scripts/ │ │ ├── .gitignore │ │ ├── emit_secret.sh │ │ ├── global_counter.sh │ │ └── no_cache_counter.sh │ ├── run-filter/ │ │ ├── cache/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── db/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── service/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── vpc/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── runner-pool-remote-source/ │ │ ├── unit-a/ │ │ │ └── terragrunt.hcl │ │ └── unit-b/ │ │ └── terragrunt.hcl │ ├── s3-backend/ │ │ ├── common.hcl │ │ ├── dual-locking/ │ │ │ └── terragrunt.hcl │ │ ├── unit1/ │ │ │ └── terragrunt.hcl │ │ ├── unit2/ │ │ │ └── terragrunt.hcl │ │ └── use-lockfile/ │ │ └── terragrunt.hcl │ ├── s3-backend-disable-init/ │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── s3-backend-migrate/ │ │ ├── unit1/ │ │ │ └── terragrunt.hcl │ │ └── unit2/ │ │ └── terragrunt.hcl │ ├── s3-encryption/ │ │ ├── basic-encryption/ │ │ │ └── terragrunt.hcl │ │ ├── custom-key/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── backend.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── sse-aes/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── sse-kms/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── s3-errors/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── scaffold/ │ │ ├── catalog-config-test/ │ │ │ └── terragrunt.hcl │ │ ├── custom-default-template/ │ │ │ ├── root.hcl │ │ │ └── unit/ │ │ │ └── .gitkeep │ │ ├── dependency-prompt-template/ │ │ │ ├── .boilerplate/ │ │ │ │ └── boilerplate.yml │ │ │ ├── base/ │ │ │ │ ├── boilerplate.yml │ │ │ │ └── test.hcl │ │ │ └── leaf/ │ │ │ ├── boilerplate.yml │ │ │ └── terragrunt.hcl │ │ ├── external-template/ │ │ │ ├── dependency/ │ │ │ │ ├── boilerplate.yml │ │ │ │ └── dependency.txt │ │ │ └── template/ │ │ │ ├── boilerplate.yml │ │ │ ├── external-template.txt │ │ │ └── terragrunt.hcl │ │ ├── module-with-template/ │ │ │ ├── .boilerplate/ │ │ │ │ ├── boilerplate.yml │ │ │ │ ├── template-file.txt │ │ │ │ └── terragrunt.hcl │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── root-hcl/ │ │ │ ├── root.hcl │ │ │ └── unit/ │ │ │ └── .gitkeep │ │ ├── scaffold-module/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── variables.tf │ │ ├── scaffold-module-tofu/ │ │ │ ├── main.tofu │ │ │ └── variables.tofu │ │ ├── with-hooks/ │ │ │ ├── .boilerplate/ │ │ │ │ ├── boilerplate.yml │ │ │ │ └── terragrunt.hcl │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── with-shell-and-hooks/ │ │ │ ├── .boilerplate/ │ │ │ │ ├── boilerplate.yml │ │ │ │ └── terragrunt.hcl │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── with-shell-commands/ │ │ ├── .boilerplate/ │ │ │ ├── boilerplate.yml │ │ │ └── terragrunt.hcl │ │ ├── .terraform.lock.hcl │ │ └── main.tf │ ├── skip/ │ │ ├── base-module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── skip-false/ │ │ │ ├── resource1/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── resource2/ │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ └── skip-true/ │ │ ├── resource1/ │ │ │ └── terragrunt.hcl │ │ ├── resource2/ │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── skip-dependencies/ │ │ ├── first/ │ │ │ ├── foo.hcl │ │ │ └── terragrunt.hcl │ │ ├── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ └── second/ │ │ └── terragrunt.hcl │ ├── skip-legacy-root/ │ │ ├── base-module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── skip-false/ │ │ │ ├── resource1/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── resource2/ │ │ │ │ └── terragrunt.hcl │ │ │ └── terragrunt.hcl │ │ └── skip-true/ │ │ ├── resource1/ │ │ │ └── terragrunt.hcl │ │ ├── resource2/ │ │ │ └── terragrunt.hcl │ │ └── terragrunt.hcl │ ├── sops/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── secrets.env │ │ ├── secrets.ini │ │ ├── secrets.json │ │ ├── secrets.txt │ │ ├── secrets.yaml │ │ ├── terragrunt.hcl │ │ └── test_pgp_key.asc │ ├── sops-errors/ │ │ ├── .terraform.lock.hcl │ │ ├── file.yaml │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── sops-kms/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── secrets.env │ │ ├── secrets.ini │ │ ├── secrets.json │ │ ├── secrets.txt │ │ ├── secrets.yaml │ │ └── terragrunt.hcl │ ├── sops-missing/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── source-map/ │ │ ├── modules/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── vpc/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── multiple-match/ │ │ │ ├── terragrunt-vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ └── terratest-vpc/ │ │ │ └── terragrunt.hcl │ │ ├── multiple-only-one-match/ │ │ │ ├── terragrunt-vpc/ │ │ │ │ └── terragrunt.hcl │ │ │ └── terratest-vpc/ │ │ │ └── terragrunt.hcl │ │ ├── multiple-with-dependency/ │ │ │ ├── app/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── multiple-with-dependency-same-url/ │ │ │ ├── app/ │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ └── terragrunt.hcl │ │ ├── single/ │ │ │ └── terragrunt.hcl │ │ └── slashes-in-ref/ │ │ └── terragrunt.hcl │ ├── stack/ │ │ ├── disjoint/ │ │ │ ├── a/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── b/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── c/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── disjoint-symlinks/ │ │ │ ├── a/ │ │ │ │ └── terragrunt.hcl │ │ │ └── module/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── main.tf │ │ ├── mgmt/ │ │ │ ├── bastion-host/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── kms-master-key/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── vpc/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── root.hcl │ │ └── stage/ │ │ ├── backend-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── frontend-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── mysql/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── redis/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── search-app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── example-module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── vpc/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── stacks/ │ │ ├── all-no-stack-dir/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── basic/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── chick/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── chicken/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── father/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── mother/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── coexist-hcl-and-stack/ │ │ │ ├── modules/ │ │ │ │ └── test/ │ │ │ │ └── main.tf │ │ │ ├── non-prod/ │ │ │ │ └── dev/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ └── test/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── test/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dependencies/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app-with-dependency/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── errors/ │ │ │ ├── absolute-path/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── cycles/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ ├── stack/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── unit/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── incorrect-source/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── api/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── locals-error/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── not-existing-path/ │ │ │ │ └── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── relative-path-outside-of-stack/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── stack-empty-path/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── unit-empty-path/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── unknown-value/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── bad-unit/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── validation-stack/ │ │ │ │ ├── live/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ ├── stacks/ │ │ │ │ │ └── v1/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── units/ │ │ │ │ └── api/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── validation-unit/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── v1/ │ │ │ ├── api/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── db/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── web/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── find-in-parent-folders/ │ │ │ ├── live/ │ │ │ │ └── stack/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── mock.hcl │ │ │ └── units/ │ │ │ └── foo/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── get-original-terragrunt-dir/ │ │ │ ├── live/ │ │ │ │ ├── account1/ │ │ │ │ │ ├── no-locals-nested/ │ │ │ │ │ │ ├── no-locals/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ ├── read-config/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ └── with-locals/ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ ├── non-nested/ │ │ │ │ │ │ ├── no-locals/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ ├── read-config/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ └── with-locals/ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ ├── read-config-nested/ │ │ │ │ │ │ ├── no-locals/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ ├── read-config/ │ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ │ └── with-locals/ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ └── with-locals-nested/ │ │ │ │ │ ├── no-locals/ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ ├── read-config/ │ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ │ └── with-locals/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── common/ │ │ │ │ └── stack_config.hcl │ │ │ ├── stacks/ │ │ │ │ ├── no-locals/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ ├── read-config/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── with-locals/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── terragrunt.hcl │ │ ├── inputs/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── locals/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── chick/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── chicken/ │ │ │ │ └── terragrunt.hcl │ │ │ ├── father/ │ │ │ │ └── terragrunt.hcl │ │ │ └── mother/ │ │ │ └── terragrunt.hcl │ │ ├── multiple-stacks/ │ │ │ ├── dev/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── nested/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── live-v2/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ ├── dev/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── prod/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── api/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── db/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── web/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── nested-outputs/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ ├── v1/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ ├── v2/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── v3/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-dot-terragrunt-stack-output/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-stack/ │ │ │ ├── config/ │ │ │ │ └── config.txt │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ └── dev/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── api/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── db/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── web/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-stack-dir/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-validation/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ └── stack1/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app1/ │ │ │ └── code/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── outputs/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ ├── app1/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── app2/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── read-stack/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ ├── dev/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── prod/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── remote/ │ │ │ └── terragrunt.stack.hcl │ │ ├── self-include/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── unit/ │ │ │ └── terragrunt.hcl │ │ ├── source-map/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── tf/ │ │ │ │ └── modules/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ └── units/ │ │ │ └── app/ │ │ │ └── terragrunt.hcl │ │ ├── stack-values/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ ├── stacks/ │ │ │ │ ├── dev/ │ │ │ │ │ └── terragrunt.stack.hcl │ │ │ │ └── prod/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── terragrunt-dir/ │ │ │ ├── live/ │ │ │ │ ├── root.hcl │ │ │ │ └── tennant_1/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── unit_a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── unit-values/ │ │ │ ├── live/ │ │ │ │ └── terragrunt.stack.hcl │ │ │ └── units/ │ │ │ └── app/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── version-constraints/ │ │ ├── live/ │ │ │ └── terragrunt.stack.hcl │ │ └── unit/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── startswith/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── strcontains/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── streaming/ │ │ ├── unit1/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── unit2/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── strict-bare-include/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── parent.hcl │ │ └── terragrunt.hcl │ ├── terragrunt-info-error/ │ │ ├── module-a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── module-b/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── terragrunt.hcl │ ├── tf-path/ │ │ ├── basic/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── other-tf.sh │ │ │ ├── terragrunt.hcl │ │ │ └── tf.sh │ │ ├── dependency/ │ │ │ ├── app/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ ├── custom-tf.sh │ │ │ └── dep/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── tofu-terraform/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── tflint/ │ │ ├── custom-tflint-config/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── custom.tflint.hcl │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── external-tflint/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── issues-found/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── module-found/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── dummy_module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ └── main.tf │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── no-config-file/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── no-issues-found/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── d1/ │ │ │ │ └── file.txt │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── no-tf-source/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── tflint-args/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── .tflint.hcl │ │ │ ├── extra.tfvars │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ └── tfvar-passing/ │ │ ├── .terraform.lock.hcl │ │ ├── .tflint.hcl │ │ ├── extra.tfvars │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── terragrunt.hcl │ │ └── variables.tf │ ├── tfr/ │ │ ├── root/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ ├── root-shorthand/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ ├── subdir/ │ │ │ ├── .terraform.lock.hcl │ │ │ └── terragrunt.hcl │ │ └── subdir-with-reference/ │ │ ├── .terraform.lock.hcl │ │ └── terragrunt.hcl │ ├── tftest/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── terragrunt.hcl │ │ └── validate_name.tftest.hcl │ ├── timecmp/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── timecmp-errors/ │ │ └── invalid-timestamp/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── tips/ │ │ └── terragrunt.hcl │ ├── tofu-http-encryption/ │ │ ├── app/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── dep/ │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── tofu-state-encryption/ │ │ ├── aws-kms/ │ │ │ └── terragrunt.hcl │ │ ├── gcp-kms/ │ │ │ └── terragrunt.hcl │ │ ├── openbao/ │ │ │ └── terragrunt.hcl │ │ └── pbkdf2/ │ │ └── terragrunt.hcl │ ├── trace-parent/ │ │ ├── .terraform.lock.hcl │ │ ├── get_traceparent.sh │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── units-reading/ │ │ ├── including/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── indirect/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── src/ │ │ │ │ └── test.txt │ │ │ └── terragrunt.hcl │ │ ├── reading-from-tf/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── reading-hcl/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── reading-hcl-and-tfvars/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── reading-json/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── reading-sops/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── reading-tfvars/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── secrets.txt │ │ ├── shared.hcl │ │ ├── shared.json │ │ ├── shared.tfvars │ │ └── test_pgp_key.asc │ ├── validate-inputs/ │ │ ├── fail-generated-var/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── variables.tf │ │ ├── fail-included-unused/ │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── fail-no-inputs/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── fail-remote-module/ │ │ │ └── terragrunt.hcl │ │ ├── fail-unused-inputs/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── fail-unused-varfile/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── varfiles/ │ │ │ └── main.tfvars │ │ ├── success-autovar-file/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── bar.auto.tfvars.json │ │ │ ├── foo.auto.tfvars │ │ │ ├── main.tf │ │ │ ├── terraform.tfvars │ │ │ ├── terraform.tfvars.json │ │ │ └── terragrunt.hcl │ │ ├── success-cli-args/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── varfiles/ │ │ │ └── main.tfvars │ │ ├── success-included/ │ │ │ ├── module/ │ │ │ │ ├── .terraform.lock.hcl │ │ │ │ ├── main.tf │ │ │ │ └── terragrunt.hcl │ │ │ └── root.hcl │ │ ├── success-inputs-only/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── success-mixed/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── terragrunt.hcl │ │ │ └── varfiles/ │ │ │ └── main.tfvars │ │ ├── success-null-default/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── success-var-file/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── terragrunt.hcl │ │ └── varfiles/ │ │ └── main.tfvars │ ├── version-check/ │ │ ├── a/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ ├── b/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ └── terragrunt.hcl │ │ └── root.hcl │ ├── version-files-cache-key/ │ │ ├── .terraform-version │ │ ├── .terraform.lock.hcl │ │ ├── .tool-versions │ │ ├── main.tf │ │ └── terragrunt.hcl │ └── version-invocation/ │ ├── app/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ ├── dependency/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ └── terragrunt.hcl │ └── dependency-with-custom-version/ │ ├── .terraform.lock.hcl │ ├── .tool-versions │ ├── main.tf │ └── terragrunt.hcl ├── helpers/ │ ├── aws.go │ ├── logger/ │ │ └── logger.go │ ├── package.go │ ├── test_helpers.go │ ├── test_helpers_unix.go │ ├── test_helpers_windows.go │ └── testcontainer_helpers.go ├── integration_aws_oidc_test.go ├── integration_aws_test.go ├── integration_awsgcp_test.go ├── integration_catalog_test.go ├── integration_common_test.go ├── integration_dag_test.go ├── integration_debug_test.go ├── integration_deprecated_test.go ├── integration_destroy_test.go ├── integration_docs_aws_test.go ├── integration_docs_aws_tofu_test.go ├── integration_docs_test.go ├── integration_download_test.go ├── integration_encryption_shared_test.go ├── integration_engine_test.go ├── integration_errors_test.go ├── integration_example_live_stacks_test.go ├── integration_exclude_test.go ├── integration_exec_test.go ├── integration_feature_flags_test.go ├── integration_filter_graph_test.go ├── integration_filter_test.go ├── integration_find_test.go ├── integration_functions_test.go ├── integration_gcp_test.go ├── integration_graph_test.go ├── integration_hcl_filter_test.go ├── integration_hooks_test.go ├── integration_include_test.go ├── integration_json_test.go ├── integration_list_test.go ├── integration_local_dev_test.go ├── integration_locals_test.go ├── integration_parse_test.go ├── integration_private_registry_test.go ├── integration_provider_cache_constraint_test.go ├── integration_queue_strict_include_test.go ├── integration_registry_test.go ├── integration_regressions_test.go ├── integration_report_test.go ├── integration_run_cmd_flags_test.go ├── integration_run_cmd_include_output_test.go ├── integration_run_test.go ├── integration_runner_pool_test.go ├── integration_s3_encryption_test.go ├── integration_scaffold_ssh_test.go ├── integration_scaffold_test.go ├── integration_serial_aws_test.go ├── integration_serial_gcp_test.go ├── integration_serial_test.go ├── integration_sops_kms_test.go ├── integration_sops_test.go ├── integration_ssh_test.go ├── integration_stacks_test.go ├── integration_strict_test.go ├── integration_test.go ├── integration_tflint_test.go ├── integration_tips_test.go ├── integration_tofu_aws_state_encryption_test.go ├── integration_tofu_openbao_test.go ├── integration_tofu_test.go ├── integration_units_reading_test.go ├── integration_unix_test.go └── integration_windows_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coderabbit.yaml ================================================ # https://docs.coderabbit.ai/guides/configure-coderabbit language: "en-US" early_access: false chat: { auto_reply: true } reviews: profile: chill high_level_summary: true poem: false collapse_walkthrough: true sequence_diagrams: true path_instructions: - path: 'docs/**/*.md' instructions: >- Review the documentation for clarity, grammar, and spelling. Make sure that the documentation is easy to understand and follow. There is currently a migration underway from the Jekyll based documentation in `docs` to the Starlight + Astro based documentation in `docs`. Whenever changes are made to the `docs` directory, ensure that an equivalent change is made in the `docs` directory to keep the `docs` documentation accurate. - path: 'docs/**/*.md*' instructions: >- Review the documentation for clarity, grammar, and spelling. Make sure that the documentation is easy to understand and follow. There is currently a migration underway from the Jekyll based documentation in `docs` to the Starlight + Astro based documentation in `docs`. Make sure that the `docs` documentation is accurate and up-to-date with the `docs` documentation, and that any difference between them results in an improvement in the `docs` documentation. - path: 'docs/**/*.astro' instructions: >- Review the Astro code in the `docs` directory for quality and correctness. Make sure that the Astro code follows best practices and is easy to understand, maintain, and follows best practices. When possible, suggest improvements to the Astro code to make it better. - path: '**/*.go' instructions: >- Review the Go code for quality and correctness. Make sure that the Go code follows best practices, is performant, and is easy to understand and maintain. tools: languagetool: enabled: true level: default ================================================ FILE: .codespellrc ================================================ [codespell] skip = go.mod,go.sum,*.svg,Gemfile.lock,bun.lock,package-lock.json,node_modules,dist ignore-words-list = dRan,implementors ================================================ FILE: .gitattributes ================================================ # Ensure golden test files are not treated as text files to prevent line ending conversions *.golden -text ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: gruntwork-io ================================================ FILE: .github/ISSUE_TEMPLATE/01-bug_report.md ================================================ --- name: Bug report about: Create a bug report to help us improve Terragrunt. title: '' labels: bug assignees: '' --- ## Describe the bug A clear and concise description of what the bug is. ## Reproducing bugs It is vital when reporting bugs that you provide the exact steps that _anyone_ would be able to follow to reproduce the bug you are seeing. Without this information, it is much less likely that maintainers will invest time in investigating and fixing the bug, and maintainers may not know if they have actually fixed the bug once a fix is made. The exceptions to requiring steps to reproduce are: 1. You are reporting a bug that you don't know how to reproduce, but you are reporting it so that others in the community are aware of it. 2. You are willing to fix the bug yourself, and you accept the responsibility of ensuring that the bug is valid, and that the fix is well tested. How to provide steps for reproduction: The most common way to provide steps for reproduction is to create a minimal example that reproduces the bug, and steps to run that example to reproduce the issue. Maintainers will refer to this example as a "fixture" when asking questions about reproduction. You can either do so with code snippets in this issue, or by creating a public Git repository that contains the minimal example, with instructions for running the example. You can delete this section right before submitting the issue, if you like. ## Steps To Reproduce Steps to reproduce the behavior, code snippets and examples which can be used to reproduce the issue. Be sure that the maintainers can actually reproduce the issue. Bug reports that are too vague or hard to reproduce are hard to troubleshoot and fix. ```hcl // paste code snippets here ``` ## Expected behavior A clear and concise description of what you expected to happen. ## Must haves - [ ] Steps for reproduction provided. ## Nice to haves - [ ] Terminal output - [ ] Screenshots ## Versions - Terragrunt version: - OpenTofu/Terraform version: - Environment details (Ubuntu 20.04, Windows 10, etc.): ## Additional context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/02-bad_error_message.md ================================================ --- name: Bad Error Message Report about: Report an error message that is unclear, unhelpful, or confusing to users. title: '' labels: - bug - error-message assignees: '' --- ## Error Message Details ### The Error Message Please paste the exact error message you received: ```bash // Paste the complete error message here ``` ## Why This Error Message is Unhelpful ### Current Problems Please explain why this error message is not helpful. ## Recommended Improvement ### What the Error Message Should Say Please suggest how the error message could be improved: ```bash // Example of what a better error message might look like ``` ## Error Reproduction Steps Provide the exact steps required for _anyone_ to successfully reproduce the error. This includes the Terragrunt command and any relevant configuration files required to reproduce the error. If you need a large amount of Terragrunt configuration to reproduce the error, consider creating a minimal reproduction repository. ### Steps to Reproduce 1. 2. 3. ## Environment Information - **Terragrunt version**: - **OpenTofu/Terraform version**: - **Operating system**: ## Additional Context Any other information that might help improve the error message. ================================================ FILE: .github/ISSUE_TEMPLATE/03-rfc.yml ================================================ # Inspired by: # - The previous RFC template. # - the OpenTofu RFC template: https://raw.githubusercontent.com/opentofu/opentofu/main/.github/ISSUE_TEMPLATE/rfc.yml name: RFC description: Submit a Request For Comments (RFC) to significantly change Terragrunt. labels: ["rfc", "pending-decision"] body: - type: markdown attributes: value: | # Request For Comments This form will guide you through the process for submitting a Request For Comments (RFC) for a change. ## Before you start - Make sure you search for an issue that would already cover your RFC in the [issues tab](https://github.com/gruntwork-io/terragrunt/issues?q=is%3Aissue). - Search through [Terragrunt documentation](https://docs.terragrunt.com/) to see if your RFC is already supported. ## After you submit - Share your RFC with your community for feedback and reactions! The more activity and feedback you get, the more likely it is to be reviewed by the maintainers. - If your RFC has enough traction, it will be reviewed by the maintainers and a decision will be made. - Once a decision is made, one of two things will happen: 1. If the RFC is accepted, the `pending-decision` label will be replaced with the `accepted` label. At this stage, pull requests can be submitted by maintainers or the community to implement the RFC, with a description including `Closes #`. 2. If the RFC is rejected, the `pending-decision` label will be replaced with the `rejected` label. The maintainers will provide a reason for the rejection. If applicable, it might be made clear that a new RFC with changes is welcome. - type: textarea id: summary attributes: label: Summary description: A brief summary of the RFC. validations: required: true - type: textarea id: motivation attributes: label: Motivation description: What is the problem you're trying to solve? What are the goals you're trying to achieve? validations: required: true - type: textarea id: proposal attributes: label: Proposal description: | In a manner that is as specific as possible, describe the proposal you have in mind. This should include: - As minimally technical a description of the proposal as possible. - An explanation of how the proposal addresses the motivation above. - Examples of how the proposal might present itself to a user if implemented. validations: required: true - type: textarea id: technical-details attributes: label: Technical Details description: | Provide technical details for the proposal. This should include: - List of components that will be affected by the proposal. - How those components will be affected. - Any documentation that will help in understanding or developing a solution to the proposal. validations: required: true - type: textarea id: press-release attributes: label: Press Release description: If this RFC were implemented, how would you describe it to the community in a mock press release? validations: required: true - type: textarea id: drawbacks attributes: label: Drawbacks description: What are the drawbacks of the proposal, if any? validations: required: false - type: textarea id: alternatives attributes: label: Alternatives description: What are the alternatives to the proposal, if any? validations: required: false - type: textarea id: migration-strategy attributes: label: Migration Strategy description: If this proposal requires a migration strategy for existing code bases, what is it? validations: required: false - type: textarea id: unresolved-questions attributes: label: Unresolved Questions description: | What parts of the proposal are still unresolved? - Is there anything that you're unsure about? - Are there any questions that you have that you haven't answered yet? - Would you like to encourage feedback on any particular part of the proposal you haven't fully fleshed out? validations: required: false - type: textarea id: references attributes: label: References description: | Are there any references that should be linked here? - Links to other RFCs, issues, or documentation. validations: required: false - type: textarea id: poc-pull-request attributes: label: Proof of Concept Pull Request description: If you have a proof of concept or an in-draft pull request that demonstrates the proposal, please link it here. validations: required: false - type: checkboxes id: support attributes: label: Support Level description: Please let us know if you are a paying customer. This helps us prioritize RFCs that are important to our customers. options: - label: I have Terragrunt Enterprise Support required: false - label: I am a paying Gruntwork customer required: false - type: input id: customer-name attributes: label: Customer Name description: If you are a paying Gruntwork customer, please provide your name. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/04-feature-request.md ================================================ --- name: Enhancement about: Request a simple feature or enhancement. title: '' labels: enhancement assignees: '' --- ## Describe the enhancement A clear and concise description of what the enhancement is. ## Additional context Add any other context about the enhancement here. Things you might want to address include: - [ ] Changes required. - [ ] Implications of the feature. - [ ] Alternatives considered. - [ ] Level of effort. ## PoC (Proof of Concept) Link to a Proof of Concept if you have one: - [ ] [PoC]() Including a PoC can help others understand the feature better and implement it faster. ## RFC Not Needed - [ ] I have evaluated the complexity of this enhancement, and I believe it does not require an RFC. ================================================ FILE: .github/assets/release-assets-config.json ================================================ { "platforms": [ { "os": "darwin", "arch": "amd64", "signed": true, "binary": "terragrunt_darwin_amd64" }, { "os": "darwin", "arch": "arm64", "signed": true, "binary": "terragrunt_darwin_arm64" }, { "os": "linux", "arch": "386", "signed": false, "binary": "terragrunt_linux_386" }, { "os": "linux", "arch": "amd64", "signed": false, "binary": "terragrunt_linux_amd64" }, { "os": "linux", "arch": "arm64", "signed": false, "binary": "terragrunt_linux_arm64" }, { "os": "windows", "arch": "386", "signed": false, "binary": "terragrunt_windows_386.exe" }, { "os": "windows", "arch": "amd64", "signed": true, "binary": "terragrunt_windows_amd64.exe" } ], "archive_formats": [ { "extension": "zip", "description": "ZIP archive" }, { "extension": "tar.gz", "description": "TAR.GZ archive" } ], "additional_files": [ { "name": "SHA256SUMS", "description": "Checksums for all files" }, { "name": "SHA256SUMS.gpgsig", "description": "GPG detached signature" }, { "name": "SHA256SUMS.sigstore.json", "description": "Cosign sigstore bundle" }, { "name": "SHA256SUMS.sig", "description": "Cosign signature (legacy)" }, { "name": "SHA256SUMS.pem", "description": "Cosign certificate (legacy)" }, { "name": "terragrunt-signing-key.asc", "description": "GPG public key for signature verification" } ] } ================================================ FILE: .github/cloud-nuke/config.yml ================================================ s3: timeout: 1h include: names_regex: - "^terragrunt-test-bucket-[a-zA-Z0-9]{6}.*" vpc: include: name_regex: - "^vpc-.*" - "^step-.*" ec2: include: name_regex: - "^single-instance$" dynamodb: include: table_names_regex: - "^terragrunt-test.*" ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" groups: go-dependencies: patterns: - "*" labels: - "go" - "dependencies" ignore: - dependency-name: "github.com/charmbracelet/glamour" versions: ["<= 0.10.1"] - dependency-name: "github.com/charmbracelet/x/ansi" # https://github.com/charmbracelet/bubbletea/issues/1448#issuecomment-3105363044 - package-ecosystem: "bun" directories: - "**/*" schedule: interval: "weekly" groups: js-dependencies: patterns: - "*" labels: - "javascript" - "dependencies" ignore: - dependency-name: "@astrojs/starlight" # Custom patch applied - manual updates required # When updating, you have to manually update the patch in the docs/package.json ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Fixes #000. ## TODOs Read the [Gruntwork contribution guidelines](https://gruntwork.notion.site/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e). - [ ] I authored this code entirely myself - [ ] I am submitting code based on open source software (e.g. MIT, MPL-2.0, Apache)] - [ ] I am adding or upgrading a dependency or adapted code and confirm it has a compatible open source license - [ ] Update the docs. - [ ] Run the relevant tests successfully, including pre-commit checks. - [ ] Include release notes. If this PR is backward incompatible, include a migration guide. ## Release Notes (draft) Added / Removed / Updated [X]. ### Migration Guide ================================================ FILE: .github/scripts/announce-release.sh ================================================ #!/usr/bin/env bash set -euo pipefail URL="${URL:?Required environment variable URL}" REPO="${REPO:?Required environment variable REPO}" TAG_NAME="${TAG_NAME:?Required environment variable TAG_NAME}" ROLE_ID="${ROLE_ID:?Required environment variable ROLE_ID}" USERNAME="${USERNAME:?Required environment variable USERNAME}" AVATAR_URL="${AVATAR_URL:?Required environment variable AVATAR_URL}" if RELEASE_JSON=$(gh -R "$REPO" release view "$TAG_NAME" --json body --json url --json name); then RELEASE_NOTES_LENGTH=$(jq '.body | length' <<<"$RELEASE_JSON") RELEASE_NOTES=$(jq '.body' <<<"$RELEASE_JSON") # This math is a little weird. # We have a budget of 200 characters for everything we add around the release notes. # We also lower the budget by 3 characters for the ellipsis we add at the end when truncating. # So, it's 2000 characters for the release notes, # minus 200 characters for everything else, # minus 3 characters for the ellipsis # = 1797 characters. if [[ "$RELEASE_NOTES_LENGTH" -gt 1800 ]]; then echo "Release notes are too long ($RELEASE_NOTES_LENGTH characters), truncating to 1797 characters, truncating the last line, then appending '…'" RELEASE_NOTES=$(jq '.body |= .[:1797]' <<<"$RELEASE_JSON" | jq '.body | split("\r\n") | del(.[-1]) | join("\r\n")' | jq '. + "\r\n…"') fi PAYLOAD=$( jq \ --argjson release_notes "$RELEASE_NOTES" \ --arg username "$USERNAME" \ --arg avatar_url "$AVATAR_URL" \ -cn '{"content": $release_notes, username: $username, avatar_url: $avatar_url, "flags": 4}' ) tmpfile=$(mktemp) jq '.content = "'"<@&$ROLE_ID> $(jq -r '.name' <<<"$RELEASE_JSON")\n"'>>> " + .content + "'"\n\n**[View release on GitHub]($(jq -r '.url' <<<"$RELEASE_JSON"))**"'"' <<<"$PAYLOAD" >"$tmpfile" jq '.content' <"$tmpfile" curl -X POST \ --data-binary "@$tmpfile" \ -H "Content-Type: application/json" \ "$URL" fi ================================================ FILE: .github/scripts/gopls/check-for-changes.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${HAS_FIXES:?}" if [[ "$HAS_FIXES" != "true" ]]; then echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --staged --quiet; then echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "has_changes=true" >> "$GITHUB_OUTPUT" ================================================ FILE: .github/scripts/gopls/create-issue.js ================================================ const fs = require('fs'); /** * Creates a GitHub issue for gopls quickfix problems found * @param {Object} params - Parameters object * @param {Object} params.github - GitHub API client * @param {Object} params.context - GitHub Actions context * @param {Object} params.core - GitHub Actions core utilities * @param {string} params.fixedFilesPath - Path to the fixed files list * @param {string} params.outputFilePath - Path to the gopls output file * @returns {Promise} The created issue number */ module.exports = async ({ github, context, core, fixedFilesPath, outputFilePath }) => { try { // Read the files that were fixed from provided paths const fixedFiles = fs.readFileSync(fixedFilesPath, 'utf8'); const goplsOutput = fs.readFileSync(outputFilePath, 'utf8'); const issueBody = `## Gopls Quickfix Issues Found The monthly gopls quickfix check found issues in the following files: \`\`\` ${fixedFiles} \`\`\` ### Details - **Run Date**: ${new Date().toISOString()} - **Workflow**: [${context.workflow}](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) - **Commit**: ${context.sha} ### Next Steps A pull request will be created to address these issues automatically. ### Full Output
Click to expand gopls output \`\`\` ${goplsOutput} \`\`\`
`; const issue = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: `🔧 Gopls Quickfix Issues Found - ${new Date().toISOString().split('T')[0]}`, body: issueBody, labels: ['gopls', 'automated', 'maintenance'] }); const issueNumber = issue.data.number; core.setOutput('issue_number', issueNumber); console.log(`Created issue #${issueNumber}`); return issueNumber; } catch (error) { core.setFailed(`Failed to create issue: ${error.message}`); throw error; } }; ================================================ FILE: .github/scripts/gopls/create-pr.js ================================================ const fs = require('fs'); /** * Creates a GitHub pull request for gopls quickfixes * @param {Object} params - Parameters object * @param {Object} params.github - GitHub API client * @param {Object} params.context - GitHub Actions context * @param {Object} params.core - GitHub Actions core utilities * @param {Object} params.exec - GitHub Actions exec utilities * @param {string} params.issueNumber - The issue number to link to (optional) * @param {string} params.fixedFilesPath - Path to the fixed files list * @returns {Promise} The created PR number */ module.exports = async ({ github, context, core, exec, issueNumber, fixedFilesPath }) => { try { const fixedFiles = fs.readFileSync(fixedFilesPath, 'utf8'); // Configure git await exec.exec('git', ['config', '--local', 'user.email', 'action@github.com']); await exec.exec('git', ['config', '--local', 'user.name', 'github-actions[bot]']); // Create branch name const branchName = `gopls-quickfix-${new Date().toISOString().split('T')[0].replace(/-/g, '')}`; // Create and push branch await exec.exec('git', ['checkout', '-b', branchName]); await exec.exec('git', ['add', '.']); await exec.exec('git', ['commit', '-m', '🔧 Apply gopls quickfixes']); await exec.exec('git', ['push', 'origin', branchName]); // Create PR const prBody = `## 🔧 Gopls Quickfixes Applied This PR applies automatic fixes suggested by gopls for code quality improvements. > [!TIP] > You might want to push an empty commit to this PR to trigger CI checks. ### Files Modified \`\`\` ${fixedFiles} \`\`\` ### Changes - Applied gopls quickfixes to improve code quality - All changes are automated and safe - Generated by monthly gopls check workflow ### Related Issue ${issueNumber ? `Closes #${issueNumber}` : ''} --- *This PR was automatically generated by the monthly gopls quickfix workflow.* `; const pr = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, title: `🔧 Apply gopls quickfixes - ${new Date().toISOString().split('T')[0]}`, body: prBody, head: branchName, base: 'main', labels: ['gopls', 'automated', 'maintenance'] }); const prNumber = pr.data.number; console.log(`Created PR #${prNumber}`); // Link PR to issue if issue was created if (issueNumber) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parseInt(issueNumber), body: `🔗 **Pull Request Created** A pull request has been created to address the gopls quickfix issues found in this issue. **PR**: #${prNumber} **Status**: Ready for review The PR will automatically close this issue when merged.` }); } return prNumber; } catch (error) { core.setFailed(`Failed to create pull request: ${error.message}`); throw error; } }; ================================================ FILE: .github/scripts/gopls/run.sh ================================================ #!/usr/bin/env bash set -euo pipefail mise use -g go:golang.org/x/tools/gopls@v0.18.1 gopls version TEMP_DIR=$(mktemp -d) FIXED_FILES="$TEMP_DIR/fixed_files.txt" FAILURES_FILE="$TEMP_DIR/gopls_failures.txt" OUTPUT_FILE="$TEMP_DIR/gopls_output.txt" touch "$FIXED_FILES" touch "$FAILURES_FILE" touch "$OUTPUT_FILE" while IFS= read -r file; do echo "START: $file" | tee -a "$OUTPUT_FILE" if gopls codeaction -kind=quickfix -write "$file"; then echo "SUCCESS: $file" | tee -a "$OUTPUT_FILE" else echo "FAILED: $file" | tee -a "$FAILURES_FILE" "$OUTPUT_FILE" echo "$file" >> "$FIXED_FILES" fi echo "END: $file" | tee -a "$OUTPUT_FILE" done < gofiles.txt printf '\n==== gopls failures (if any) ====\n' | tee -a "$OUTPUT_FILE" tee -a "$OUTPUT_FILE" < "$FAILURES_FILE" || true # Check if any files were modified if [[ -s "$FIXED_FILES" ]]; then echo "has_fixes=true" >> "$GITHUB_OUTPUT" echo "Files with fixes:" | tee -a "$OUTPUT_FILE" tee -a "$OUTPUT_FILE" < "$FIXED_FILES" else echo "has_fixes=false" >> "$GITHUB_OUTPUT" echo "No files were modified by gopls quickfixes" | tee -a "$OUTPUT_FILE" fi # Output file paths for other steps to use echo "fixed_files_path=$FIXED_FILES" >> "$GITHUB_OUTPUT" echo "output_file_path=$OUTPUT_FILE" >> "$GITHUB_OUTPUT" ================================================ FILE: .github/scripts/release/README.md ================================================ # Release Scripts This directory contains scripts used by the GitHub Actions release workflow to build, sign, and publish Terragrunt releases. ## Overview All inline bash and PowerShell code from GitHub Actions workflows has been extracted into these standalone scripts for better: - **Syntax highlighting** - Proper IDE support - **Linting** - Can run shellcheck/PSScriptAnalyzer - **Testing** - Scripts can be tested independently - **Maintainability** - Easier to update and debug - **Reusability** - Can be called from multiple workflows ## Script Overview ### Core Library - **`lib-release-config.sh`** - Helper library to read centralized release configuration from JSON ### General Release Scripts - **`get-version.sh`** - Extracts and validates version from git tag or workflow input - **`check-release-exists.sh`** - Checks if GitHub release exists for a version - **`verify-binaries-downloaded.sh`** - Verifies expected binaries were downloaded from artifacts - **`set-permissions.sh`** - Sets executable permissions (+x) on all binaries - **`create-archives.sh`** - Creates ZIP and TAR.GZ archives for each binary - **`generate-checksums.sh`** - Generates SHA256SUMS file for all release files - **`verify-files.sh`** - Verifies all required files are present before upload - **`upload-assets.sh`** - Uploads all assets to GitHub release (with optional --clobber) - **`verify-assets-uploaded.sh`** - Verifies uploads and retries missing files - **`generate-upload-summary.sh`** - Generates GitHub Actions step summary with release details ### macOS Signing Scripts - **`prepare-macos-artifacts.sh`** - Prepares macOS artifacts for signing (filters darwin_* binaries) - **`install-gon.sh`** - Downloads and installs gon tool for macOS code signing - **`sign-macos-binaries.sh`** - Signs macOS binaries using gon and Apple notarization ### Windows Signing Scripts - **`prepare-windows-artifacts.ps1`** - Prepares Windows artifacts for signing - **`install-go-winres.ps1`** - Installs go-winres tool for patching Windows resources - **`verify-smctl.ps1`** - Verifies DigiCert smctl tool is installed and accessible - **`restore-p12-certificate.ps1`** - Restores P12 client certificate from base64 encoding - **`sign-windows.ps1`** - Config-driven: patches all binaries, signs only those marked `signed: true` ## Centralized Configuration Release asset configuration is maintained in a single source of truth: ### `../assets/release-assets-config.json` JSON file defining all platforms, binaries, archive formats, and additional files. **Schema:** ```json { "platforms": [ { "os": "darwin|linux|windows", "arch": "386|amd64|arm64", "signed": true|false, "binary": "terragrunt__[.exe]" } ], "archive_formats": [ { "extension": "zip|tar.gz", "description": "Format description" } ], "additional_files": [ { "name": "SHA256SUMS", "description": "File description" } ] } ``` ### `lib-release-config.sh` Helper library providing functions to read the centralized configuration. **Usage:** ```bash #!/bin/bash source "$(dirname "$0")/lib-release-config.sh" # Verify config file exists verify_config_file # Get all binary filenames get_all_binaries # Get binary count get_binary_count # Get total expected file count get_total_file_count # Get archive extensions get_archive_extensions # Get additional files get_additional_files # Get all expected files (binaries + archives + additional) get_all_expected_files # Get platform info for specific binary get_platform_info "terragrunt_darwin_amd64" # Generate markdown table rows for summary generate_platform_table_rows ``` **Scripts Using Configuration:** - `verify-binaries-downloaded.sh` - Uses `get_binary_count()` - `set-permissions.sh` - Uses `get_all_binaries()` - `verify-assets-uploaded.sh` - Uses `get_all_expected_files()`, `get_total_file_count()`, `get_binary_count()` - `generate-upload-summary.sh` - Uses `get_binary_count()`, `get_total_file_count()`, `generate_platform_table_rows()` ## General Scripts ### `get-version.sh` Extracts version from either workflow dispatch input or git tag. **Environment Variables:** - `INPUT_TAG`: Tag provided via workflow_dispatch - `EVENT_NAME`: GitHub event name (workflow_dispatch or push) - `GITHUB_REF`: Git reference (e.g., refs/tags/v0.93.4) - `GITHUB_OUTPUT`: Path to GitHub output file **Usage:** ```bash .github/scripts/release/get-version.sh ``` ### `check-release-exists.sh` Checks if a GitHub release exists for a given tag using the GitHub CLI. **Environment Variables:** - `VERSION`: The version/tag to check for - `GH_TOKEN`: GitHub token for authentication - `GITHUB_OUTPUT`: Path to GitHub output file **Usage:** ```bash export VERSION=v0.93.4 export GH_TOKEN= .github/scripts/release/check-release-exists.sh ``` ### `verify-binaries-downloaded.sh` Verifies all expected binaries were downloaded from build artifacts. **Parameters:** - `bin-directory`: Directory containing binaries (default: `bin`) - `expected-count`: Minimum number of binaries expected (default: `7`) **Usage:** ```bash .github/scripts/release/verify-binaries-downloaded.sh bin 7 ``` **Features:** - Lists all downloaded binaries with details - Counts total files using `find` - Validates minimum expected count - Exits with error if count is below threshold ### `set-permissions.sh` Sets executable permissions (+x) on all Terragrunt binaries. **Usage:** ```bash .github/scripts/release/set-permissions.sh bin ``` ### `create-archives.sh` Creates both ZIP and TAR.GZ archives for each binary, preserving execute permissions. **Usage:** ```bash .github/scripts/release/create-archives.sh bin ``` **Output:** - Creates `.zip` and `.tar.gz` for each binary - ZIP files preserve Unix permissions - TAR.GZ files natively preserve all file attributes ### `generate-checksums.sh` Generates SHA256 checksums for all release files (binaries and archives). **Usage:** ```bash .github/scripts/release/generate-checksums.sh bin ``` **Output:** - Creates `SHA256SUMS` file with checksums for all files ### `verify-files.sh` Verifies all required files are present before upload. **Usage:** ```bash .github/scripts/release/verify-files.sh bin ``` **Checks:** - All platform binaries (macOS, Linux, Windows) - SHA256SUMS file ### `upload-assets.sh` Uploads all release assets to an existing GitHub release. **Environment Variables:** - `VERSION`: The version/tag to upload to - `GH_TOKEN`: GitHub token for authentication **Usage:** ```bash export VERSION=v0.93.4 export GH_TOKEN= .github/scripts/release/upload-assets.sh bin ``` ### `verify-assets-uploaded.sh` Verifies all assets were successfully uploaded and retries any missing files. **Environment Variables:** - `VERSION`: The version/tag to verify - `GH_TOKEN`: GitHub token for authentication - `CLOBBER`: Set to 'true' to overwrite existing assets during retry (default: false) **Usage:** ```bash export VERSION=v0.93.4 export GH_TOKEN= export CLOBBER=false .github/scripts/release/verify-assets-uploaded.sh bin ``` **Features:** - Checks for 22 expected files (7 binaries + 7 ZIPs + 7 TAR.GZ + SHA256SUMS) - Automatically retries failed uploads (max 10 attempts) - Verifies asset downloadability ### `generate-upload-summary.sh` Generates a GitHub Actions step summary with release upload details. **Environment Variables:** - `VERSION`: Release version/tag - `RELEASE_ID`: GitHub release ID - `IS_DRAFT`: Whether release was a draft - `GITHUB_STEP_SUMMARY`: Path to GitHub step summary file **Usage:** ```bash export VERSION=v0.93.4 export RELEASE_ID=123456 export IS_DRAFT=false export GITHUB_STEP_SUMMARY=$GITHUB_STEP_SUMMARY .github/scripts/release/generate-upload-summary.sh ``` **Features:** - Creates formatted markdown summary - Shows release details (version, ID, draft status) - Displays platform/architecture table - Lists archive files and totals - Always runs (even on failure) via `if: always()` in workflow ## macOS Scripts ### `prepare-macos-artifacts.sh` Prepares macOS artifacts for signing by copying them from the artifacts directory to the bin directory. **Usage:** ```bash .github/scripts/release/prepare-macos-artifacts.sh artifacts bin ``` ### `install-gon.sh` Downloads and installs the gon binary for macOS code signing and notarization. **Environment Variables:** - `GON_VERSION`: Version of gon to install (default: v0.0.37) **Usage:** ```bash export GON_VERSION=v0.0.37 .github/scripts/release/install-gon.sh # or pass version as argument .github/scripts/release/install-gon.sh v0.0.37 ``` **Features:** - Downloads gon from GitHub releases - Installs to `/usr/local/bin` - Verifies installation - Cleans up temporary files ### `sign-macos-binaries.sh` Signs macOS binaries using gon and Apple notarization service. **Environment Variables:** - `AC_PASSWORD`: Apple Connect password - `AC_PROVIDER`: Apple Connect provider - `AC_USERNAME`: Apple Connect username - `MACOS_CERTIFICATE`: macOS certificate in P12 format (base64 encoded) - `MACOS_CERTIFICATE_PASSWORD`: Certificate password **Usage:** ```bash export AC_PASSWORD= export AC_PROVIDER= export AC_USERNAME= export MACOS_CERTIFICATE= export MACOS_CERTIFICATE_PASSWORD= .github/scripts/release/sign-macos-binaries.sh bin ``` **Process:** 1. Signs amd64 binary using `.gon_amd64.hcl` 2. Signs arm64 binary using `.gon_arm64.hcl` 3. Removes unsigned binaries from bin directory 4. Extracts signed binaries from ZIP archives 5. Moves signed binaries to bin directory (replacing unsigned versions) 6. Verifies signatures using `codesign -dv --verbose=4` ## Windows Scripts ### `prepare-windows-artifacts.ps1` Prepares Windows artifacts for signing by copying them from the artifacts directory to the bin directory. **Parameters:** - `-ArtifactsDirectory`: Source directory (default: `artifacts`) - `-BinDirectory`: Destination directory (default: `bin`) **Usage:** ```powershell .github/scripts/release/prepare-windows-artifacts.ps1 -ArtifactsDirectory artifacts -BinDirectory bin ``` ### `install-go-winres.ps1` Installs go-winres tool and adds it to PATH. **Usage:** ```powershell .github/scripts/release/install-go-winres.ps1 ``` **Features:** - Installs go-winres from GitHub - Adds Go bin directory to PATH - Exports PATH to GitHub environment - Verifies installation with `go-winres help` ### `verify-smctl.ps1` Verifies that DigiCert smctl tool is installed and accessible. **Usage:** ```powershell .github/scripts/release/verify-smctl.ps1 ``` **Checks:** - smctl.exe is in PATH - Displays smctl version - Confirms tool is ready for use ### `restore-p12-certificate.ps1` Restores P12 client certificate from base64 encoding. **Environment Variables:** - `WINDOWS_SIGNING_P12_BASE64`: Base64 encoded P12 certificate (required) - `RUNNER_TEMP`: Temporary directory for certificate file - `GITHUB_ENV`: Path to GitHub environment file **Usage:** ```powershell $env:WINDOWS_SIGNING_P12_BASE64 = "" .github/scripts/release/restore-p12-certificate.ps1 ``` **Output:** - Creates certificate file in `$RUNNER_TEMP/sm_client_auth.p12` - Exports `SM_CLIENT_CERT_FILE` environment variable ### `sign-windows.ps1` Comprehensive Windows binary signing script using DigiCert KeyLocker. **Fully driven by configuration**. **Environment Variables:** - `GITHUB_REF_NAME`: Git ref name (e.g., v0.93.4 or beta-2025111001) - `SM_HOST`: DigiCert host URL - `SM_API_KEY`: DigiCert API key - `SM_CLIENT_CERT_FILE`: Path to P12 certificate file - `SM_CLIENT_CERT_PASSWORD`: Certificate password - `SM_KEYPAIR_ALIAS`: DigiCert keypair alias **Parameters:** - `-BinDirectory`: Directory containing binaries (default: `bin`) **Usage:** ```powershell .github/scripts/release/sign-windows.ps1 -BinDirectory bin ``` **Process:** 1. **Configuration Loading**: Reads `release-assets-config.json` to discover all Windows platforms 2. **Version Detection**: Parses git ref to extract version - Standard: `v0.93.4` → `0.93.4` - Pre-release: `beta-2025111001` → `2025.1110.01.0` - Generic: `-YYYYMMDDNN` → `YYYY.MMDD.NN.0` 3. **Resource Generation**: Dynamically generates `winres.json` with version info, icon, manifest, and metadata 4. **Binary Patching**: Uses go-winres to patch **all** Windows binaries with icon and metadata (regardless of signing status) 5. **Credential Setup**: Saves DigiCert credentials 6. **Healthcheck**: Runs smctl healthcheck 7. **Certificate Sync**: Syncs certificates from DigiCert KeyLocker 8. **Selective Signing**: For each Windows platform in config: - If `"signed": true` → Signs with DigiCert and verifies signature - If `"signed": false` → Skips signing (patching only) 9. **Summary**: Reports how many binaries were signed vs. patched-only **Configuration-Driven Behavior:** The script reads `.github/assets/release-assets-config.json` to determine: - Which Windows binaries exist (`binary` field) - Which binaries should be signed (`signed: true/false`) - All patching decisions are automated based on JSON **Example Config:** ```json { "platforms": [ { "os": "windows", "arch": "386", "signed": false, "binary": "terragrunt_windows_386.exe" }, { "os": "windows", "arch": "amd64", "signed": true, "binary": "terragrunt_windows_amd64.exe" } ] } ``` With this config, the script will: - Patch both binaries with icon/manifest - Sign only the amd64 binary (conserving signature quota) - Leave 386 unsigned ## Testing ### Bash Scripts ```bash # Install shellcheck sudo apt-get install shellcheck # Ubuntu/Debian # or brew install shellcheck # macOS # Check all bash scripts shellcheck .github/scripts/release/*.sh # Test individual script export VERSION=v0.93.4 export GH_TOKEN= .github/scripts/release/get-version.sh ``` ### PowerShell Scripts ```powershell # Install PSScriptAnalyzer Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser # Analyze all PowerShell scripts Get-ChildItem .github/scripts/release/*.ps1 | ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName } # Test individual script .github/scripts/release/verify-smctl.ps1 ``` ## Workflow Integration These scripts are used by: - **`.github/workflows/release.yml`** - Main release workflow - Uses: `get-version.sh`, `check-release-exists.sh`, `set-permissions.sh`, `create-archives.sh`, `generate-checksums.sh`, `verify-files.sh`, `upload-assets.sh`, `verify-assets-uploaded.sh` - **`.github/workflows/sign-macos.yml`** - macOS signing workflow - Uses: `prepare-macos-artifacts.sh`, `install-gon.sh`, `sign-macos-binaries.sh` - **`.github/workflows/sign-windows.yml`** - Windows signing workflow - Uses: `prepare-windows-artifacts.ps1`, `install-go-winres.ps1`, `verify-smctl.ps1`, `restore-p12-certificate.ps1`, `sign-windows.ps1` ## Version Format Support The scripts support multiple version tag formats: | Format | Example | Windows FileVersion | Description | |----------|--------------------|---------------------|---------------------------------| | Standard | `v0.93.4` | `0.93.4.0` | Semantic version with v prefix | | Beta | `beta-2025111001` | `2025.1110.01.0` | Pre-release with date timestamp | | Alpha | `alpha-2025110301` | `2025.1103.01.0` | Alpha with date timestamp | **Windows Version Constraints:** - Each component must be ≤ 65535 - Format: YYYY.MMDD.NN.0 keeps all components within limits ## Security Notes - All scripts use proper quoting to prevent command injection - Environment variables are validated before use - Sensitive data (tokens, passwords) passed via environment variables only - Scripts fail fast on errors: - Bash: `set -e` - PowerShell: `$ErrorActionPreference = 'Stop'` - No secrets are logged or printed to stdout - Certificate files are stored in temporary directories ## Script Conventions ### Bash Scripts - Use `#!/bin/bash` shebang - Enable fail-fast: `set -e` - Use functions for organization - Validate environment variables with `assert_env_var_not_empty` - Accept directory paths as arguments (default: `bin`) - Use `printf` instead of `echo` for variable output - Proper quoting: `"$variable"` not `$variable` ### PowerShell Scripts - Use strict error handling: `$ErrorActionPreference = 'Stop'` - Use parameters with defaults: `param([string]$BinDirectory = "bin")` - Check exit codes: `if ($LASTEXITCODE -ne 0) { exit 1 }` - Organize code into functions - Use `Write-Host` for informational output - Use `Write-Error` for errors ================================================ FILE: .github/scripts/release/check-release-exists.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to check if a GitHub release exists for a given tag # Usage: check-release-exists.sh # Environment variables: # VERSION: The version/tag to check for # GH_TOKEN: GitHub token for authentication # GITHUB_OUTPUT: Path to GitHub output file function main { # Validate required environment variables : "${VERSION:?ERROR: VERSION is a required environment variable}" : "${GH_TOKEN:?ERROR: GH_TOKEN is a required environment variable}" : "${GITHUB_OUTPUT:?ERROR: GITHUB_OUTPUT is a required environment variable}" printf 'Checking if release exists for tag: %s\n' "$VERSION" # Check if release exists using gh CLI (only care about exit code) if ! gh release view "$VERSION" > /dev/null 2>&1; then printf 'exists=false\n' >> "$GITHUB_OUTPUT" printf 'Release not found for tag %s\n' "$VERSION" exit 1 fi # Get release details local release_json release_json=$(gh release view "$VERSION" --json 'id,uploadUrl,isDraft') local release_id local upload_url local is_draft release_id=$(jq -r '.id' <<< "$release_json") upload_url=$(jq -r '.uploadUrl' <<< "$release_json") is_draft=$(jq -r '.isDraft' <<< "$release_json") # Write to GitHub output printf 'exists=true\n' >> "$GITHUB_OUTPUT" printf 'release_id=%s\n' "$release_id" >> "$GITHUB_OUTPUT" printf 'upload_url=%s\n' "$upload_url" >> "$GITHUB_OUTPUT" printf 'is_draft=%s\n' "$is_draft" >> "$GITHUB_OUTPUT" echo "Found existing release:" printf ' Release ID: %s\n' "$release_id" printf ' Draft: %s\n' "$is_draft" printf ' Upload URL: %s\n' "${upload_url%\{*}" return 0 } main "$@" ================================================ FILE: .github/scripts/release/create-archives.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to create ZIP and TAR.GZ archives for each binary # Usage: create-archives.sh function main { local -r bin_dir="${1:-bin}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi # Use pushd/popd to avoid side effects on caller's working directory pushd "$bin_dir" || return 1 echo "Creating individual archives for each binary..." # Create individual ZIP and TAR.GZ archives for each binary (preserving execute permissions) for binary in terragrunt_*; do # Skip if it's already an archive file if [[ "$binary" == *.zip ]] || [[ "$binary" == *.tar.gz ]]; then continue fi # Create ZIP archive zip "$binary.zip" "$binary" echo "Created: $binary.zip" # Create TAR.GZ archive (preserves Unix permissions including +x) tar -czf "$binary.tar.gz" "$binary" echo "Created: $binary.tar.gz" done echo "" echo "All individual archives created:" echo "ZIP archives:" ls -lh *.zip echo "" echo "TAR.GZ archives:" ls -lh *.tar.gz # Return to original directory popd || return 1 return 0 } main "$@" ================================================ FILE: .github/scripts/release/generate-checksums.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to generate SHA256 checksums for all release files # Usage: generate-checksums.sh function main { local -r bin_dir="${1:-bin}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi # Use pushd/popd to avoid side effects on caller's working directory pushd "$bin_dir" || return 1 # Generate checksums for all files including individual ZIPs and TAR.GZ archives sha256sum terragrunt_* > SHA256SUMS echo "SHA256SUMS generated:" cat SHA256SUMS echo "" echo "Total files with checksums: $(wc -l < SHA256SUMS)" # Return to original directory popd || return 1 return 0 } main "$@" ================================================ FILE: .github/scripts/release/generate-upload-summary.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to generate GitHub Actions step summary for release uploads # Usage: generate-upload-summary.sh # Environment variables: # VERSION: Release version/tag # RELEASE_ID: GitHub release ID # IS_DRAFT: Whether release was a draft # GITHUB_STEP_SUMMARY: Path to GitHub step summary file # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" main() { require_env_vars VERSION RELEASE_ID IS_DRAFT GITHUB_STEP_SUMMARY verify_config_file echo "Generating upload summary..." local binary_count binary_count=$(get_binary_count) local total_count total_count=$(get_total_file_count) cat >>"$GITHUB_STEP_SUMMARY" <>"$GITHUB_STEP_SUMMARY" cat >>"$GITHUB_STEP_SUMMARY" <&2 missing=1 fi done if [[ "$missing" -eq 1 ]]; then exit 1 fi return 0 } main "$@" ================================================ FILE: .github/scripts/release/get-version.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to get the version from either workflow dispatch input or git ref # Usage: get-version.sh # Environment variables: # INPUT_TAG: Tag provided via workflow_dispatch # EVENT_NAME: GitHub event name (workflow_dispatch or push) # GITHUB_REF: Git reference (e.g., refs/tags/v0.93.4) # GITHUB_OUTPUT: Path to GitHub output file function resolve_version { # Handle workflow_dispatch event (manual trigger with INPUT_TAG) if [[ "$EVENT_NAME" = "workflow_dispatch" ]]; then if [[ -z "$INPUT_TAG" ]]; then echo "ERROR: INPUT_TAG is empty for workflow_dispatch event" >&2 exit 1 fi echo "$INPUT_TAG" return 0 fi # Handle push event (tag push with GITHUB_REF) if [[ -z "$GITHUB_REF" ]]; then echo "ERROR: GITHUB_REF is empty" >&2 exit 1 fi if [[ ! "$GITHUB_REF" =~ ^refs/tags/ ]]; then echo "ERROR: GITHUB_REF does not start with 'refs/tags/': $GITHUB_REF" >&2 exit 1 fi # Strip refs/tags/ prefix and return echo "${GITHUB_REF#refs/tags/}" return 0 } function validate_version { local -r version="$1" if [[ -z "$version" ]]; then echo "ERROR: Extracted version is empty" >&2 exit 1 fi # Validate version matches expected pattern (tag-like: starts with letter/digit) if [[ ! "$version" =~ ^[a-zA-Z0-9] ]]; then echo "ERROR: Invalid version format: '$version' (must start with alphanumeric character)" >&2 exit 1 fi return 0 } function main { local version version=$(resolve_version) validate_version "$version" # Write to GitHub output printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" printf 'Release version: %s\n' "$version" return 0 } main "$@" ================================================ FILE: .github/scripts/release/install-go-winres.ps1 ================================================ # Script to install go-winres tool # Usage: install-go-winres.ps1 $ErrorActionPreference = 'Stop' Write-Host "Installing go-winres..." # Install go-winres go install github.com/tc-hib/go-winres@latest if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install go-winres" exit 1 } # Add Go bin to PATH $goPath = & go env GOPATH $goBinPath = Join-Path $goPath "bin" $env:PATH = "$goBinPath;$env:PATH" # Export PATH to GitHub environment echo "$goBinPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append Write-Host "go-winres installed to: $goBinPath" Write-Host "" Write-Host "Verifying go-winres installation..." & go-winres help if ($LASTEXITCODE -ne 0) { Write-Error "go-winres verification failed" exit 1 } Write-Host "" Write-Host "go-winres installed successfully" ================================================ FILE: .github/scripts/release/install-gon.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to download and install gon binary for macOS code signing # Usage: install-gon.sh [gon-version] # Environment variables: # GON_VERSION: Version of gon to install (default: v0.0.37) function main { local gon_version="${1:-${GON_VERSION:-v0.0.37}}" echo "Installing gon version $gon_version..." # Download gon release local download_url="https://github.com/Bearer/gon/releases/download/${gon_version}/gon_macos.zip" echo "Downloading gon from: $download_url" if ! curl -L -o gon.zip "$download_url"; then echo "ERROR: Failed to download gon from $download_url" >&2 rm -f gon.zip exit 1 fi # Extract to specific target echo "Extracting gon binary..." if ! unzip -o gon.zip -d . gon; then echo "ERROR: Failed to extract gon from gon.zip" >&2 rm -f gon.zip exit 1 fi # Verify extracted binary exists and is a regular file if [[ ! -f ./gon ]]; then echo "ERROR: Expected file './gon' not found after extraction" >&2 rm -f gon.zip exit 1 fi # Make executable echo "Setting executable permissions..." if ! chmod +x ./gon; then echo "ERROR: Failed to set executable permissions on ./gon" >&2 rm -f gon.zip ./gon exit 1 fi # Verify it's executable if [[ ! -x ./gon ]]; then echo "ERROR: File ./gon is not executable after chmod" >&2 rm -f gon.zip ./gon exit 1 fi # Move to system path echo "Moving gon to /usr/local/bin/" if ! sudo mv ./gon /usr/local/bin/gon; then echo "ERROR: Failed to move gon to /usr/local/bin/" >&2 rm -f gon.zip ./gon exit 1 fi if ! sudo chmod +x /usr/local/bin/gon; then echo "ERROR: Failed to set executable permissions on /usr/local/bin/gon" >&2 exit 1 fi # Verify installation echo "Verifying gon installation..." if ! gon --version; then echo "ERROR: gon --version failed after installation" >&2 exit 1 fi # Cleanup rm -f gon.zip echo "gon installed successfully" return 0 } main "$@" ================================================ FILE: .github/scripts/release/lib-release-config.sh ================================================ #!/usr/bin/env bash # Library script to read release assets configuration # Usage: source .github/scripts/release/lib-release-config.sh readonly RELEASE_CONFIG_FILE=".github/assets/release-assets-config.json" # Get list of all binary filenames get_all_binaries() { jq -r '.platforms[].binary' "$RELEASE_CONFIG_FILE" } # Get total binary count (computed from platforms array) get_binary_count() { jq -r '.platforms | length' "$RELEASE_CONFIG_FILE" } # Get total expected file count (computed: binaries + archives + additional files) get_total_file_count() { jq -r ' (.platforms | length) as $binaries | (.archive_formats | length) as $formats | (.additional_files | length) as $additional | $binaries + ($binaries * $formats) + $additional ' "$RELEASE_CONFIG_FILE" } # Get list of archive extensions get_archive_extensions() { jq -r '.archive_formats[].extension' "$RELEASE_CONFIG_FILE" } # Get list of additional files get_additional_files() { jq -r '.additional_files[].name' "$RELEASE_CONFIG_FILE" } # Generate expected files list (for verification) get_all_expected_files() { local binaries # Get binaries binaries=$(get_all_binaries) # Generate list: binaries + archives + additional files echo "$binaries" # Add archives for each binary for binary in $binaries; do while IFS= read -r ext; do echo "${binary}.${ext}" done < <(get_archive_extensions) done # Add additional files get_additional_files return 0 } # Get platform info as JSON for a specific binary get_platform_info() { local binary="$1" jq --arg binary "$binary" '.platforms[] | select(.binary == $binary)' "$RELEASE_CONFIG_FILE" } # Generate markdown table rows for summary generate_platform_table_rows() { jq -r '.platforms[] | "| \(.os | ascii_downcase) | \(.arch) | \(if .signed then "Yes" else "No" end) | Uploaded |"' "$RELEASE_CONFIG_FILE" | awk '{ # Capitalize first letter of OS if ($2 == "darwin") $2 = "macOS" else if ($2 == "linux") $2 = "Linux" else if ($2 == "windows") $2 = "Windows" print }' } # Check if config file exists verify_config_file() { if [[ ! -f "$RELEASE_CONFIG_FILE" ]]; then echo "ERROR: Release config file not found: $RELEASE_CONFIG_FILE" >&2 return 1 fi return 0 } ================================================ FILE: .github/scripts/release/prepare-macos-artifacts.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to prepare macOS artifacts for signing # Usage: prepare-macos-artifacts.sh function main { local -r artifacts_dir="${1:-artifacts}" local -r bin_dir="${2:-bin}" if [[ ! -d "$artifacts_dir" ]]; then echo "ERROR: Artifacts directory $artifacts_dir does not exist" >&2 exit 1 fi echo "Preparing macOS build artifacts..." # Create bin directory mkdir -p "$bin_dir" # Copy only macOS artifacts (terragrunt_darwin_*) to bin directory find "$artifacts_dir" -type f -name 'terragrunt_darwin_*' -exec cp {} "$bin_dir/" \; # Verify we found macOS binaries if ! ls "$bin_dir"/terragrunt_darwin_* > /dev/null 2>&1; then echo "ERROR: No macOS binaries (terragrunt_darwin_*) found in $artifacts_dir" >&2 exit 1 fi echo "Binary files to sign:" ls -lahrt "$bin_dir"/* return 0 } main "$@" ================================================ FILE: .github/scripts/release/prepare-windows-artifacts.ps1 ================================================ # Script to prepare Windows artifacts for signing # Usage: prepare-windows-artifacts.ps1 -ArtifactsDirectory -BinDirectory param( [Parameter(Mandatory=$false)] [string]$ArtifactsDirectory = "artifacts", [Parameter(Mandatory=$false)] [string]$BinDirectory = "bin" ) $ErrorActionPreference = 'Stop' Write-Host "Preparing Windows build artifacts..." # Create bin directory New-Item -ItemType Directory -Force -Path $BinDirectory | Out-Null # Check if artifacts directory exists if (-not (Test-Path $ArtifactsDirectory)) { Write-Error "Artifacts directory not found: $ArtifactsDirectory" exit 1 } # Copy Windows binaries to bin directory Get-ChildItem -Path $ArtifactsDirectory -Filter "terragrunt_windows_*" -Recurse -File | ForEach-Object { Copy-Item $_.FullName -Destination $BinDirectory/ Write-Host "Copied: $($_.Name)" } Write-Host "" Write-Host "Binary files to sign:" Get-ChildItem -Path $BinDirectory | ForEach-Object { Write-Host $_.FullName } Write-Host "" Write-Host "Artifacts prepared successfully" ================================================ FILE: .github/scripts/release/restore-p12-certificate.ps1 ================================================ # Script to restore P12 client certificate from base64 # Usage: restore-p12-certificate.ps1 # Environment variables: # WINDOWS_SIGNING_P12_BASE64: Base64 encoded P12 certificate (required) # RUNNER_TEMP: Temporary directory for certificate file # GITHUB_ENV: Path to GitHub environment file $ErrorActionPreference = 'Stop' Write-Host "Restoring P12 client certificate from base64..." # Get certificate from environment variable $base64Certificate = $env:WINDOWS_SIGNING_P12_BASE64 if ([string]::IsNullOrEmpty($base64Certificate)) { Write-Error "ERROR: Required environment variable WINDOWS_SIGNING_P12_BASE64 not set." exit 1 } # Decode base64 certificate $bytes = [Convert]::FromBase64String($base64Certificate) # Generate output path $path = Join-Path $env:RUNNER_TEMP "sm_client_auth.p12" # Write certificate to file [IO.File]::WriteAllBytes($path, $bytes) # Verify file was created if (Test-Path $path) { Write-Host "Certificate file created: $path" $fileInfo = Get-Item $path Write-Host "Size: $($fileInfo.Length) bytes" } else { Write-Error "Failed to create certificate file" exit 1 } # Export to GitHub environment echo "SM_CLIENT_CERT_FILE=$path" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "" Write-Host "SM_CLIENT_CERT_FILE set to: $path" Write-Host "Certificate restored successfully" ================================================ FILE: .github/scripts/release/set-permissions.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to set execution permissions on binaries # Usage: set-permissions.sh # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" function main { local -r bin_dir="${1:-bin}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi verify_config_file # Use pushd/popd to avoid side effects on caller's working directory pushd "$bin_dir" || return 1 # Get list of all binaries from configuration local binaries mapfile -t binaries < <(get_all_binaries) # Set execution permissions on all binaries for binary in "${binaries[@]}"; do if [[ -f "$binary" ]]; then chmod +x "$binary" echo "Set +x on $binary" else echo "Warning: Binary $binary not found, skipping" >&2 fi done echo "Execution permissions set on all binaries" # Return to original directory popd || return 1 return 0 } main "$@" ================================================ FILE: .github/scripts/release/sign-checksums.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to sign SHA256SUMS with GPG and Cosign # Usage: sign-checksums.sh # # Environment variables: # GPG_FINGERPRINT - GPG key fingerprint for signing (required) # SIGNING_GPG_PASSPHRASE - GPG key passphrase (required) # # Outputs: # SHA256SUMS.gpgsig - GPG detached signature # SHA256SUMS.sigstore.json - Cosign sigstore bundle # SHA256SUMS.sig - Cosign signature (legacy, extracted from bundle) # SHA256SUMS.pem - Cosign certificate (legacy, extracted from bundle) function main { local -r bin_dir="${1:-bin}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi if [[ -z "${GPG_FINGERPRINT}" ]]; then echo "ERROR: GPG_FINGERPRINT environment variable is not set" >&2 exit 1 fi if [[ -z "${SIGNING_GPG_PASSPHRASE}" ]]; then echo "ERROR: SIGNING_GPG_PASSPHRASE environment variable is not set" >&2 exit 1 fi # Use pushd/popd to avoid side effects on caller's working directory pushd "$bin_dir" || exit 1 if [[ ! -f "SHA256SUMS" ]]; then echo "ERROR: SHA256SUMS file not found in $bin_dir" >&2 popd || exit 1 exit 1 fi # GPG signing echo "Signing SHA256SUMS with GPG..." gpg --batch --yes -u "${GPG_FINGERPRINT}" \ --pinentry-mode loopback \ --passphrase "${SIGNING_GPG_PASSPHRASE}" \ --output SHA256SUMS.gpgsig \ --detach-sign SHA256SUMS echo "GPG signature created: SHA256SUMS.gpgsig" # Cosign signing (keyless OIDC) - produces sigstore bundle echo "Signing SHA256SUMS with Cosign..." cosign sign-blob SHA256SUMS \ --bundle=SHA256SUMS.sigstore.json \ --yes echo "Cosign bundle created: SHA256SUMS.sigstore.json" # Extract legacy .sig and .pem from bundle for backward compatibility echo "Extracting legacy signature files from bundle..." jq -r '.messageSignature.signature' SHA256SUMS.sigstore.json > SHA256SUMS.sig jq -r '.verificationMaterial.certificate.rawBytes' SHA256SUMS.sigstore.json | \ base64 --decode | \ openssl x509 -inform DER -outform PEM -out SHA256SUMS.pem echo "Cosign signature created: SHA256SUMS.sig" echo "Cosign certificate created: SHA256SUMS.pem" echo "" echo "All signatures generated successfully:" ls -la SHA256SUMS* popd || exit 1 return 0 } main "$@" ================================================ FILE: .github/scripts/release/sign-macos-binaries.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to sign macOS binaries using gon and Apple notarization # Usage: sign-macos-binaries.sh # Environment variables: # AC_PASSWORD: Apple Connect password # AC_PROVIDER: Apple Connect provider # AC_USERNAME: Apple Connect username # MACOS_CERTIFICATE: macOS certificate in P12 format (base64 encoded) # MACOS_CERTIFICATE_PASSWORD: Certificate password function main { local -r bin_dir="${1:-bin}" # Validate required environment variables : "${AC_PASSWORD:?ERROR: AC_PASSWORD is a required environment variable}" : "${AC_PROVIDER:?ERROR: AC_PROVIDER is a required environment variable}" : "${AC_USERNAME:?ERROR: AC_USERNAME is a required environment variable}" : "${MACOS_CERTIFICATE:?ERROR: MACOS_CERTIFICATE is a required environment variable}" : "${MACOS_CERTIFICATE_PASSWORD:?ERROR: MACOS_CERTIFICATE_PASSWORD is a required environment variable}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi echo "Signing macOS binaries..." # Sign amd64 binary echo "Signing amd64 binary..." .github/scripts/setup/mac-sign.sh .gon_amd64.hcl # Sign arm64 binary echo "Signing arm64 binary..." .github/scripts/setup/mac-sign.sh .gon_arm64.hcl echo "Done signing the binaries" # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" verify_config_file # Get list of macOS binaries from config (compatible with bash 3.2+) local macos_binaries macos_binaries=$(jq -r '.platforms[] | select(.os == "darwin") | .binary' "$RELEASE_CONFIG_FILE") echo "Expected macOS binaries from config: $macos_binaries" # Remove old unsigned binaries from bin directory echo "Removing unsigned binaries from $bin_dir..." for binary in $macos_binaries; do rm -f "$bin_dir/$binary" echo " Removed: $bin_dir/$binary" done # Extract and verify signed binaries echo "" echo "Extracting and verifying signed binaries..." for binary in $macos_binaries; do local zip_file="${binary}.zip" echo "Processing $binary..." # Check ZIP file exists [[ -f "$zip_file" ]] || { echo "ERROR: ZIP file $zip_file not found for binary $binary" >&2 exit 1 } echo " Found $zip_file, extracting..." unzip -o "$zip_file" # Check extraction succeeded [[ -f "$binary" ]] || { echo " ERROR: Failed to extract binary $binary from $zip_file" >&2 exit 1 } echo " Extracted binary exists, checking signature..." codesign -dv --verbose=4 "$binary" 2>&1 || { echo " ERROR: Signature verification failed for binary $binary" >&2 exit 1 } echo " Signature verified" mv "$binary" "$bin_dir/" echo " Moved signed binary to $bin_dir/" # Also move the ZIP file to bin directory mv "$zip_file" "$bin_dir/" echo " Moved $zip_file to $bin_dir/" echo "" done # Final verification of all binaries in bin directory echo "Final verification of all binaries in $bin_dir..." for binary in $macos_binaries; do echo "Verifying $binary..." [[ -f "$bin_dir/$binary" ]] || { echo "ERROR: Binary $bin_dir/$binary not found after processing" >&2 exit 1 } codesign -dv --verbose=4 "$bin_dir/$binary" || { echo "ERROR: Signature verification failed for binary $bin_dir/$binary" >&2 exit 1 } done echo "" echo "All macOS binaries signed and verified successfully" # Show final contents of bin directory for debugging echo "" echo "Final contents of $bin_dir directory:" ls -lah "$bin_dir/" return 0 } main "$@" ================================================ FILE: .github/scripts/release/sign-windows.ps1 ================================================ # Script to sign Windows binaries using DigiCert and patch with go-winres # Usage: sign-windows.ps1 -BinDirectory # Environment variables: # GITHUB_REF_NAME: Git ref name (e.g., v0.93.4 or beta-2025111001) # SM_HOST: DigiCert host # SM_API_KEY: DigiCert API key # SM_CLIENT_CERT_FILE: Path to P12 certificate file # SM_CLIENT_CERT_PASSWORD: Certificate password # SM_KEYPAIR_ALIAS: DigiCert keypair alias param( [Parameter(Mandatory=$false)] [string]$BinDirectory = "bin" ) $ErrorActionPreference = 'Stop' # Path to centralized configuration $ConfigFile = ".github/assets/release-assets-config.json" function Assert-EnvVar { param([string]$Name) $value = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($value)) { Write-Error "ERROR: Required environment variable $Name not set." exit 1 } } function Get-WindowsPlatforms { # Read configuration file if (-not (Test-Path $ConfigFile)) { Write-Error "Configuration file not found: $ConfigFile" exit 1 } Write-Host "Reading configuration from: $ConfigFile" $config = Get-Content $ConfigFile -Raw | ConvertFrom-Json # Filter Windows platforms $windowsPlatforms = $config.platforms | Where-Object { $_.os -eq "windows" } if ($windowsPlatforms.Count -eq 0) { Write-Error "No Windows platforms found in configuration" exit 1 } Write-Host "Found $($windowsPlatforms.Count) Windows platform(s) in configuration" return $windowsPlatforms } function Update-WinresVersion { # Get version from git ref $rawVersion = $env:GITHUB_REF_NAME if ([string]::IsNullOrEmpty($rawVersion) -or $rawVersion -eq "refs/heads/main") { $rawVersion = "0.0.0-dev" } Write-Host "Raw version from git ref: $rawVersion" # Parse version based on tag format $version = "" $major = "0" $minor = "0" $patch = "0" $build = "0" if ($rawVersion -match '^v(\d+)\.(\d+)\.(\d+)') { # Standard version tag: v0.93.4 $major = $matches[1] $minor = $matches[2] $patch = $matches[3] $version = "$major.$minor.$patch" Write-Host "Detected standard version tag: v$version" } elseif ($rawVersion -match '^([a-zA-Z0-9_-]+)-(\d{4})(\d{2})(\d{2})(\d{2})') { # Generic pre-release tag: -YYYYMMDDNN # Examples: beta-2025111001, alpha-2025110301, rc-2025120101, dev-2025110501 # Extract prefix and date components: prefix-YYYY-MM-DD-build $prefix = $matches[1] $year = $matches[2] $month = $matches[3] $day = $matches[4] $buildNum = $matches[5] # Windows FileVersion components are limited to 65535 # Use format: YYYY.MMDD.NN.0 (all components within limits) $major = $year $minor = "$month$day" $patch = $buildNum $version = "$prefix-$year$month$day$buildNum" Write-Host "Detected pre-release tag: $version (Prefix: $prefix, FileVersion will be $year.$month$day.$buildNum.0)" } elseif ($rawVersion -match '^\d+\.\d+') { # Version without 'v' prefix $version = $rawVersion $versionParts = $version.Split('.') $major = if ($versionParts.Length -gt 0) { $versionParts[0] } else { "0" } $minor = if ($versionParts.Length -gt 1) { $versionParts[1] } else { "0" } $patch = if ($versionParts.Length -gt 2) { $versionParts[2].Split('-')[0] } else { "0" } Write-Host "Detected version without prefix: $version" } else { # Branch name or dev version $major = "0" $minor = "0" $patch = "0" $version = "0.0.0-dev" Write-Host "Using dev version: $version" } $fileVersion = "$major.$minor.$patch.0" $copyrightYear = (Get-Date).Year Write-Host "Final version: $version" Write-Host "File version (for Windows): $fileVersion" Write-Host "" # Generate winres.json dynamically Write-Host "Generating winres.json..." $winresConfig = @{ RT_GROUP_ICON = @{ APP = @{ "0409" = ".github/assets/terragrunt.png" } } RT_MANIFEST = @{ "#1" = @{ "0409" = @{ assembly = @{ identity = @{ name = "Terragrunt" version = $fileVersion } description = "Terragrunt - Orchestrate OpenTofu and Terraform at Scale" } compatibility = @{ application = @( @{ supportedOS = @{ Id = "{e2011457-1546-43c5-a5fe-008deee3d3f0}" comment = "Windows Vista / Windows Server 2008" } }, @{ supportedOS = @{ Id = "{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" comment = "Windows 7 / Windows Server 2008 R2" } }, @{ supportedOS = @{ Id = "{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" comment = "Windows 8 / Windows Server 2012" } }, @{ supportedOS = @{ Id = "{1f676c76-80e1-4239-95bb-83d0f6d0da78}" comment = "Windows 8.1 / Windows Server 2012 R2" } }, @{ supportedOS = @{ Id = "{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" comment = "Windows 10, Windows 11 / Windows Server 2016, 2019, 2022" } } ) } dpiAwareness = "PerMonitorV2, PerMonitor" } } } RT_VERSION = @{ "#1" = @{ "0409" = @{ fixed = @{ file_version = $fileVersion product_version = $fileVersion } info = @{ "0409" = @{ Comments = "Standardize IaC and manage growing infra complexity: define units, stacks, cut repetition with includes/hooks, execute modules in dependency order across environments" CompanyName = "Gruntwork, Inc." FileDescription = "Terragrunt - Orchestrate OpenTofu and Terraform at Scale" FileVersion = $version InternalName = "terragrunt" LegalCopyright = "Copyright (C) $copyrightYear Gruntwork, Inc." OriginalFilename = "terragrunt.exe" ProductName = "Terragrunt" ProductVersion = $version } } } } } } # Write winres.json to current directory $jsonOutput = $winresConfig | ConvertTo-Json -Depth 10 -Compress:$false [System.IO.File]::WriteAllText("winres.json", $jsonOutput) Write-Host "Generated winres.json:" Get-Content winres.json # Verify icon file exists Write-Host "" Write-Host "Verifying icon file..." if (Test-Path ".github/assets/terragrunt.png") { Write-Host "Icon file exists: .github/assets/terragrunt.png" $iconInfo = Get-Item ".github/assets/terragrunt.png" Write-Host "Icon size: $($iconInfo.Length) bytes" } else { Write-Error "Icon file not found: .github/assets/terragrunt.png" exit 1 } } function Patch-Binaries { param([array]$Platforms) # Add Go bin to PATH $goPath = & go env GOPATH $goBinPath = Join-Path $goPath "bin" $env:PATH = "$goBinPath;$env:PATH" Write-Host "Patching Windows binaries with icon and version info..." foreach ($platform in $Platforms) { $binaryPath = Join-Path $BinDirectory $platform.binary if (Test-Path $binaryPath) { Write-Host "Patching $($platform.binary) ($($platform.arch))..." & go-winres patch --in winres.json $binaryPath if ($LASTEXITCODE -ne 0) { Write-Error "Failed to patch $($platform.binary)" exit 1 } Write-Host "Successfully patched $($platform.binary)" } else { Write-Error "Binary not found: $binaryPath" exit 1 } } Write-Host "All Windows binaries patched with resources" } function Save-Credentials { Write-Host "Saving credentials to Windows Credential Manager..." & smctl.exe credentials save $env:SM_API_KEY $env:SM_CLIENT_CERT_PASSWORD if ($LASTEXITCODE -ne 0) { Write-Error "Failed to save credentials" exit 1 } Write-Host "Credentials saved to Windows Credential Manager" } function Invoke-Healthcheck { Write-Host "Running smctl healthcheck..." & smctl.exe healthcheck if ($LASTEXITCODE -ne 0) { Write-Warning "Healthcheck failed (exit code: $LASTEXITCODE)" Write-Warning "Continuing anyway - signing step will be the real test" } else { Write-Host "Healthcheck passed" } } function Sync-Certificates { Write-Host "Syncing certificates from DigiCert KeyLocker..." & smctl.exe windows certsync --keypair-alias "$env:SM_KEYPAIR_ALIAS" if ($LASTEXITCODE -ne 0) { Write-Error "Certificate sync failed" exit 1 } Write-Host "Certificates synced to Windows store" } function Sign-Binary { param([string]$BinaryPath) Write-Host "Signing: $BinaryPath" & smctl.exe sign ` --keypair-alias "$env:SM_KEYPAIR_ALIAS" ` --input "$BinaryPath" ` --simple ` --verbose if ($LASTEXITCODE -ne 0) { Write-Error "Signing failed for $BinaryPath" exit 1 } Write-Host "Successfully signed $BinaryPath" } function Verify-Signature { param([string]$BinaryPath) Write-Host "Verifying signature on: $BinaryPath" & smctl.exe sign verify --input "$BinaryPath" if ($LASTEXITCODE -ne 0) { Write-Warning "Signature verification returned non-zero exit code (may be expected)" } else { Write-Host "Signature verified successfully" } } function Main { # Verify environment variables Assert-EnvVar "SM_HOST" Assert-EnvVar "SM_API_KEY" Assert-EnvVar "SM_CLIENT_CERT_FILE" Assert-EnvVar "SM_CLIENT_CERT_PASSWORD" Assert-EnvVar "SM_KEYPAIR_ALIAS" Assert-EnvVar "GITHUB_REF_NAME" if (-not (Test-Path $BinDirectory)) { Write-Error "Directory $BinDirectory does not exist" exit 1 } # Get Windows platforms from configuration $windowsPlatforms = Get-WindowsPlatforms # Update winres.json with version info Update-WinresVersion # Patch all Windows binaries with resources (icon, manifest, version info) Patch-Binaries -Platforms $windowsPlatforms # Save credentials Save-Credentials # Run healthcheck Invoke-Healthcheck # Sync certificates Sync-Certificates # Sign binaries based on configuration Write-Host "" Write-Host "Processing Windows binaries for signing..." Write-Host "" $signedCount = 0 $unsignedCount = 0 foreach ($platform in $windowsPlatforms) { $binaryPath = Join-Path $BinDirectory $platform.binary if (-not (Test-Path $binaryPath)) { Write-Error "Binary not found: $binaryPath" exit 1 } if ($platform.signed -eq $true) { Write-Host "Signing $($platform.binary) ($($platform.arch))..." Sign-Binary -BinaryPath $binaryPath Write-Host "Verifying signature on $($platform.binary)..." Verify-Signature -BinaryPath $binaryPath Write-Host "✓ $($platform.binary): signed and verified" $signedCount++ } else { Write-Host "○ $($platform.binary) ($($platform.arch)): patched with resources only (not signed per config)" $unsignedCount++ } Write-Host "" } Write-Host "Windows processing completed successfully:" Write-Host " - Signed: $signedCount binary(ies)" Write-Host " - Patched only: $unsignedCount binary(ies)" Write-Host "" Write-Host "All decisions based on configuration: $ConfigFile" } Main ================================================ FILE: .github/scripts/release/upload-assets.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to upload release assets to GitHub # Usage: upload-assets.sh # Environment variables: # VERSION: The version/tag to upload to # GH_TOKEN: GitHub token for authentication # CLOBBER: Set to 'true' to overwrite existing assets (default: false) function main { local -r bin_dir="${1:-bin}" local -r clobber="${CLOBBER:-false}" # Validate required environment variables : "${VERSION:?ERROR: VERSION is a required environment variable}" : "${GH_TOKEN:?ERROR: GH_TOKEN is a required environment variable}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi # Build upload command with optional --clobber flag local clobber_flag="" if [[ "$clobber" == "true" ]]; then clobber_flag="--clobber" echo "Note: --clobber enabled - will overwrite existing assets" else echo "Note: --clobber disabled - will fail if assets already exist" fi printf 'Uploading assets to existing release %s...\n' "$VERSION" # Use pushd/popd to avoid side effects on caller's working directory pushd "$bin_dir" || return 1 # Upload all files using gh CLI for file in *; do echo "Uploading $file..." if gh release upload "$VERSION" "$file" $clobber_flag; then echo "Uploaded $file" else echo "Upload failed for $file (will retry in verification)" >&2 fi done # Return to original directory popd || return 1 echo "Upload phase completed" return 0 } main "$@" ================================================ FILE: .github/scripts/release/verify-assets-uploaded.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to verify all assets were uploaded to the GitHub release # Usage: verify-assets-uploaded.sh # Environment variables: # VERSION: The version/tag to verify # GH_TOKEN: GitHub token for authentication # CLOBBER: Set to 'true' to overwrite existing assets during retry (default: false) # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" readonly MAX_RETRIES=10 function main { local -r bin_dir="${1:-bin}" local -r clobber="${CLOBBER:-false}" # Validate required environment variables : "${VERSION:?ERROR: VERSION is a required environment variable}" : "${GH_TOKEN:?ERROR: GH_TOKEN is a required environment variable}" verify_config_file # Build upload command with optional --clobber flag local clobber_flag="" if [[ "$clobber" == "true" ]]; then clobber_flag="--clobber" fi echo "Verifying all assets are accessible..." # Get list of assets in the release local assets assets=$(gh release view "$VERSION" --json 'assets' --jq '.assets[].name') local asset_count asset_count=$(wc -l <<< "$assets") echo "Found $asset_count assets in release" # Get expected files from centralized configuration local expected_files mapfile -t expected_files < <(get_all_expected_files) # Check each expected file for expected_file in "${expected_files[@]}"; do echo "Checking $expected_file..." # Check if file exists in release if ! grep -q "^${expected_file}$" <<< "$assets"; then echo "$expected_file not found in release, uploading..." # Upload the missing file if [[ -f "$bin_dir/$expected_file" ]]; then local i for ((i=0; i&2 sleep 5 done if (( i == MAX_RETRIES )); then echo "Failed to upload $expected_file after $MAX_RETRIES retries" >&2 exit 1 fi else echo "File $bin_dir/$expected_file not found locally" >&2 exit 1 fi else echo "$expected_file present" fi done # Verify we can download assets (spot check) echo "" echo "Verifying asset downloads (spot check)..." local download_url download_url=$(gh release view "$VERSION" --json 'assets' --jq '.assets[0].url') if curl -sILf "$download_url" > /dev/null; then echo "Assets are downloadable" else echo "Warning: Could not verify asset download URL" >&2 fi local expected_count expected_count=$(get_total_file_count) local binary_count binary_count=$(get_binary_count) echo "" echo "All required assets verified!" echo "Expected files: $expected_count ($binary_count binaries + archives + checksums)" echo "Actual files: $asset_count" if [[ "$asset_count" -lt "$expected_count" ]]; then echo "Warning: Expected $expected_count files, found $asset_count" >&2 fi return 0 } main "$@" ================================================ FILE: .github/scripts/release/verify-binaries-downloaded.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to verify all expected binaries were downloaded # Usage: verify-binaries-downloaded.sh [expected-count] # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" function resolve_expected_count { local -r count_override="$1" # If override provided, use it; otherwise get from config if [[ -n "$count_override" ]]; then echo "$count_override" return 0 fi verify_config_file get_binary_count return 0 } function main { local -r bin_dir="${1:-bin}" local -r count_override="${2:-}" local expected_count expected_count=$(resolve_expected_count "$count_override") if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi # Count binaries first local binary_count binary_count=$(find "$bin_dir/" -type f | wc -l) # List binaries if any exist (resilient to empty directory) if [[ "$binary_count" -gt 0 ]]; then echo "Downloaded binaries:" ls -lahrt "$bin_dir"/* else echo "No binaries found in $bin_dir" fi echo "Total binaries: $binary_count" # Verify expected count echo "Expected: at least $expected_count binaries" if [[ "$binary_count" -lt "$expected_count" ]]; then echo "ERROR: Expected at least $expected_count binaries, found $binary_count" >&2 exit 1 fi echo "All binaries present ($binary_count files)" return 0 } main "$@" ================================================ FILE: .github/scripts/release/verify-files.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to verify all required files are present before upload # Usage: verify-files.sh # Source configuration library # shellcheck source=lib-release-config.sh source "$(dirname "$0")/lib-release-config.sh" function main { local -r bin_dir="${1:-bin}" if [[ ! -d "$bin_dir" ]]; then echo "ERROR: Directory $bin_dir does not exist" >&2 exit 1 fi verify_config_file echo "Verifying required files..." # Get all binaries from configuration local binaries mapfile -t binaries < <(get_all_binaries) # Check each binary for file in "${binaries[@]}"; do if [[ -f "$bin_dir/$file" ]]; then echo "$file present" else echo "$file missing" >&2 exit 1 fi done # Check additional files from configuration local additional_files mapfile -t additional_files < <(get_additional_files) for file in "${additional_files[@]}"; do if [[ -f "$bin_dir/$file" ]]; then echo "$file present" else echo "$file missing" >&2 exit 1 fi done echo "All required files verified" return 0 } main "$@" ================================================ FILE: .github/scripts/release/verify-smctl.ps1 ================================================ # Script to verify smctl installation # Usage: verify-smctl.ps1 $ErrorActionPreference = 'Stop' Write-Host "Checking smctl installation..." # Check if smctl is in PATH $smctlPath = Get-Command smctl.exe -ErrorAction SilentlyContinue if (-not $smctlPath) { Write-Error "smctl.exe not found in PATH" exit 1 } Write-Host "smctl found at: $($smctlPath.Source)" Write-Host "Checking smctl version..." # Capture stderr and stdout $output = & smctl.exe --version 2>&1 # Check exit code if ($LASTEXITCODE -ne 0) { Write-Error "smctl --version failed with exit code $LASTEXITCODE. Output: $output" exit $LASTEXITCODE } # Display output if successful Write-Host $output Write-Host "" Write-Host "smctl is ready" ================================================ FILE: .github/scripts/release/verify-static-binary.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Script to verify that binaries are statically linked # Usage: verify-static-binary.sh die() { echo "ERROR: $*" >&2 exit 1 } require_arg() { local -r value="$1" local -r name="$2" [[ -n "$value" ]] || die "$name is required" return 0 } expect_empty() { local -r value="$1" local -r message="$2" [[ -z "$value" ]] || { echo "$message" >&2 echo "$value" >&2 exit 1 } return 0 } verify_linux_binary() { local -r binary="$1" local -r file_info="$2" echo "Verifying Linux binary is statically linked..." echo "$file_info" grep -q "statically linked" <<<"$file_info" echo "Linux binary is statically linked" # Verify with ldd - it should either say "not a dynamic executable" or "statically linked" local ldd_output ldd_output=$(ldd "$binary" 2>&1 || true) grep -qE "not a dynamic executable|statically linked" <<<"$ldd_output" echo "Linux binary has no dynamic dependencies" return 0 } verify_darwin_binary() { local -r binary="$1" local -r file_info="$2" echo "Verifying macOS binary..." echo "$file_info" grep -q "Mach-O.*executable" <<<"$file_info" echo "macOS binary is Mach-O executable" return 0 } verify_windows_binary() { local -r binary="$1" local -r file_info="$2" echo "Verifying Windows binary..." echo "$file_info" grep -q "PE32.*executable.*Windows" <<<"$file_info" echo "Windows binary is PE32 executable" local unexpected_dlls unexpected_dlls=$( objdump -p "$binary" 2>/dev/null | grep -i "DLL Name" | grep -vi "KERNEL32.dll\|msvcrt.dll\|WS2_32.dll\|ADVAPI32.dll\|SHELL32.dll\|ole32.dll" || true ) expect_empty "$unexpected_dlls" "Windows binary links to unexpected DLLs:" echo "Windows binary has standard system DLL dependencies only" return 0 } main() { local -r binary="${1:-}" local -r os="${2:-}" local -r arch="${3:-}" require_arg "$binary" "binary path" require_arg "$os" "os" require_arg "$arch" "arch" [[ -f "$binary" ]] || die "Binary $binary does not exist" echo "Verifying static linking for $binary ($os/$arch)..." local file_info file_info=$(file "$binary") case "$os" in linux) verify_linux_binary "$binary" "$file_info" ;; darwin) verify_darwin_binary "$binary" "$file_info" ;; windows) verify_windows_binary "$binary" "$file_info" ;; *) die "Unsupported OS: $os" ;; esac echo "Static linking verification passed for $os/$arch!" return 0 } main "$@" ================================================ FILE: .github/scripts/setup/cas.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" touch "$ENV_FILE" printf "export TG_EXPERIMENT='%s'\n" "cas" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/engine.sh ================================================ #!/usr/bin/env bash set -euo pipefail export TOFU_ENGINE_VERSION="v0.0.20" export REPO="gruntwork-io/terragrunt-engine-opentofu" export ASSET_NAME="terragrunt-iac-engine-opentofu_rpc_${TOFU_ENGINE_VERSION}_linux_amd64.zip" pushd . # Download the engine binary mkdir -p /tmp/engine cd /tmp/engine wget -O "engine.zip" "https://github.com/${REPO}/releases/download/${TOFU_ENGINE_VERSION}/${ASSET_NAME}" unzip -o "engine.zip" popd ================================================ FILE: .github/scripts/setup/experiment-mode.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" touch "$ENV_FILE" printf "export TG_EXPERIMENT_MODE=%s\n" "true" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/gcp.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" touch "$ENV_FILE" echo "$GCLOUD_SERVICE_KEY" > "${HOME}/gcloud-service-key.json" export GOOGLE_APPLICATION_CREDENTIALS="${HOME}/gcloud-service-key.json" printf "export GOOGLE_APPLICATION_CREDENTIALS='%s'\n" "${HOME}/gcloud-service-key.json" >> "$ENV_FILE" # Save gcloud commands to ENV_FILE printf "gcloud auth activate-service-account --key-file=\"%s\" --quiet\n" "${HOME}/gcloud-service-key.json" >> "$ENV_FILE" printf "gcloud config set project '%s'\n" "${GOOGLE_PROJECT_ID}" >> "$ENV_FILE" printf "export GOOGLE_CLOUD_PROJECT='%s'\n" "${GOOGLE_PROJECT_ID}" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/generate-mocks.sh ================================================ #!/usr/bin/env bash set -euo pipefail make generate-mocks ================================================ FILE: .github/scripts/setup/generate-secrets.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Required environment variables : "${NAME:?NAME is not set}" : "${ENV_FILE:?ENV_FILE is not set}" : "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is not set}" : "${GHA_DEPLOY_KEY:?GHA_DEPLOY_KEY is not set}" : "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is not set}" : "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is not set}" : "${AWS_TEST_S3_ASSUME_ROLE:?AWS_TEST_S3_ASSUME_ROLE is not set}" : "${AWS_TEST_OIDC_ROLE_ARN:?AWS_TEST_OIDC_ROLE_ARN is not set}" : "${GCLOUD_SERVICE_KEY:?GCLOUD_SERVICE_KEY is not set}" : "${GOOGLE_CLOUD_PROJECT:?GOOGLE_CLOUD_PROJECT is not set}" : "${GOOGLE_COMPUTE_ZONE:?GOOGLE_COMPUTE_ZONE is not set}" : "${GOOGLE_IDENTITY_EMAIL:?GOOGLE_IDENTITY_EMAIL is not set}" : "${GOOGLE_PROJECT_ID:?GOOGLE_PROJECT_ID is not set}" : "${GCLOUD_SERVICE_KEY_IMPERSONATOR:?GCLOUD_SERVICE_KEY_IMPERSONATOR is not set}" # Optional environment variables SECRETS="${SECRETS:-}" touch "$ENV_FILE" # Manually export each secret listed in matrix.integration.secrets for SECRET in $SECRETS; do if [[ "$SECRET" == "GHA_DEPLOY_KEY" && -n "${GHA_DEPLOY_KEY}" ]]; then printf "export GHA_DEPLOY_KEY='%s'\n" "${GHA_DEPLOY_KEY}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_ACCESS_KEY_ID" && -n "${AWS_ACCESS_KEY_ID}" ]]; then printf "export AWS_ACCESS_KEY_ID='%s'\n" "${AWS_ACCESS_KEY_ID}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_SECRET_ACCESS_KEY" && -n "${AWS_SECRET_ACCESS_KEY}" ]]; then printf "export AWS_SECRET_ACCESS_KEY='%s'\n" "${AWS_SECRET_ACCESS_KEY}" >> "$ENV_FILE" elif [[ "$SECRET" == "GCLOUD_SERVICE_KEY" && -n "${GCLOUD_SERVICE_KEY}" ]]; then printf "export GCLOUD_SERVICE_KEY='%s'\n" "${GCLOUD_SERVICE_KEY}" >> "$ENV_FILE" printf "export GOOGLE_SERVICE_ACCOUNT_JSON='%s'\n" "${GCLOUD_SERVICE_KEY}" >> "$ENV_FILE" elif [[ "$SECRET" == "GOOGLE_CLOUD_PROJECT" && -n "${GOOGLE_CLOUD_PROJECT}" ]]; then printf "export GOOGLE_CLOUD_PROJECT='%s'\n" "${GOOGLE_CLOUD_PROJECT}" >> "$ENV_FILE" elif [[ "$SECRET" == "GOOGLE_COMPUTE_ZONE" && -n "${GOOGLE_COMPUTE_ZONE}" ]]; then printf "export GOOGLE_COMPUTE_ZONE='%s'\n" "${GOOGLE_COMPUTE_ZONE}" >> "$ENV_FILE" elif [[ "$SECRET" == "GOOGLE_IDENTITY_EMAIL" && -n "${GOOGLE_IDENTITY_EMAIL}" ]]; then printf "export GOOGLE_IDENTITY_EMAIL='%s'\n" "${GOOGLE_IDENTITY_EMAIL}" >> "$ENV_FILE" elif [[ "$SECRET" == "GOOGLE_PROJECT_ID" && -n "${GOOGLE_PROJECT_ID}" ]]; then printf "export GOOGLE_PROJECT_ID='%s'\n" "${GOOGLE_PROJECT_ID}" >> "$ENV_FILE" elif [[ "$SECRET" == "GCLOUD_SERVICE_KEY_IMPERSONATOR" && -n "${GCLOUD_SERVICE_KEY_IMPERSONATOR}" ]]; then printf "export GCLOUD_SERVICE_KEY_IMPERSONATOR='%s'\n" "${GCLOUD_SERVICE_KEY_IMPERSONATOR}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_ACCESS_KEY_ID" && -n "${AWS_ACCESS_KEY_ID}" ]]; then printf "export AWS_ACCESS_KEY_ID='%s'\n" "${AWS_ACCESS_KEY_ID}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_SECRET_ACCESS_KEY" && -n "${AWS_SECRET_ACCESS_KEY}" ]]; then printf "export AWS_SECRET_ACCESS_KEY='%s'\n" "${AWS_SECRET_ACCESS_KEY}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_TEST_S3_ASSUME_ROLE" && -n "${AWS_TEST_S3_ASSUME_ROLE}" ]]; then printf "export AWS_TEST_S3_ASSUME_ROLE='%s'\n" "${AWS_TEST_S3_ASSUME_ROLE}" >> "$ENV_FILE" elif [[ "$SECRET" == "AWS_TEST_OIDC_ROLE_ARN" && -n "${AWS_TEST_OIDC_ROLE_ARN}" ]]; then printf "export AWS_TEST_OIDC_ROLE_ARN='%s'\n" "${AWS_TEST_OIDC_ROLE_ARN}" >> "$ENV_FILE" fi done echo "Created environment file with secrets for $NAME" ================================================ FILE: .github/scripts/setup/mac-sign.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Apple certificate used to validate developer certificates https://www.apple.com/certificateauthority/ readonly APPLE_ROOT_CERTIFICATE="http://certs.apple.com/devidg2.der" function print_usage { echo echo "Usage: $0 [OPTIONS] " echo printf ' MACOS_CERTIFICATE\t\tMac developer certificate in P12 format, encoded in base64.\n' printf ' MACOS_CERTIFICATE_PASSWORD\tMac certificate password\n' echo echo "Optional Arguments:" printf ' --macos-skip-root-certificate\t\tSkip importing Apple Root certificate. Useful when running in already configured environment.\n' printf ' --help\t\t\t\tShow this help text and exit.\n' echo echo "Examples:" echo " $0 sign.hcl" return 0 } function main { local mac_skip_root_certificate="" local assets=() while [[ $# -gt 0 ]]; do local key="$1" case "$key" in --macos-skip-root-certificate) mac_skip_root_certificate=true shift ;; --help) print_usage exit ;; -* ) echo "ERROR: Unrecognized argument: $key" >&2 print_usage exit 1 ;; * ) assets=("$@") break esac done ensure_macos import_certificate_mac "${mac_skip_root_certificate}" sign_mac "${assets[@]}" return 0 } function ensure_macos { if [[ $OSTYPE != 'darwin'* ]]; then echo "Signing of Mac binaries is supported only on MacOS" >&2 exit 1 fi return 0 } function sign_mac { local -r assets=("$@") local gon_cmd="gon" for filepath in "${assets[@]}"; do echo "Signing ${filepath}" "${gon_cmd}" -log-level=info "${filepath}" done return 0 } function import_certificate_mac { local -r mac_skip_root_certificate="$1" assert_env_var_not_empty "MACOS_CERTIFICATE" assert_env_var_not_empty "MACOS_CERTIFICATE_PASSWORD" trap "rm -rf /tmp/*-keychain" EXIT local mac_certificate_pwd="${MACOS_CERTIFICATE_PASSWORD}" local keystore_pw="${RANDOM}" # create separated keychain file to store certificate and do quick cleanup of sensitive data local db_file db_file=$(mktemp "/tmp/XXXXXX-keychain") rm -rf "${db_file}" echo "Creating separated keychain for certificate" security create-keychain -p "${keystore_pw}" "${db_file}" security default-keychain -s "${db_file}" security unlock-keychain -p "${keystore_pw}" "${db_file}" echo "${MACOS_CERTIFICATE}" | base64 -d | security import /dev/stdin -f pkcs12 -k "${db_file}" -P "${mac_certificate_pwd}" -T /usr/bin/codesign if [[ "${mac_skip_root_certificate}" == "" ]]; then # download apple root certificate used as root for developer certificate curl -v "${APPLE_ROOT_CERTIFICATE}" --output certificate.der sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certificate.der fi security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keystore_pw}" "${db_file}" return 0 } function assert_env_var_not_empty { local -r var_name="$1" local -r var_value="${!var_name}" if [[ -z "$var_value" ]]; then echo "ERROR: Required environment $var_name not set." >&2 exit 1 fi return 0 } main "$@" ================================================ FILE: .github/scripts/setup/provider-cache-server.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" touch "$ENV_FILE" printf "export TG_PROVIDER_CACHE='%s'\n" "1" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/run-setup-scripts.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Required environment variables : "${ENV_FILE:?ENV_FILE is not set}" # Optional environment variables SETUP_SCRIPTS="${SETUP_SCRIPTS:-}" # Source the environment file # shellcheck source=/dev/null source "${ENV_FILE}" # Loop through setup scripts and execute them for SCRIPT in $SETUP_SCRIPTS; do echo "Running setup script: $SCRIPT" "$SCRIPT" echo "Setup script $SCRIPT completed" done ================================================ FILE: .github/scripts/setup/sops.sh ================================================ #!/usr/bin/env bash set -euo pipefail gpg --import --no-tty --batch --yes ./test/fixtures/sops/test_pgp_key.asc ================================================ FILE: .github/scripts/setup/ssh.sh ================================================ #!/usr/bin/env bash set -euo pipefail SSH_KEY="${GHA_DEPLOY_KEY:?Required environment variable GHA_DEPLOY_KEY}" mkdir -p ~/.ssh echo "$SSH_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ================================================ FILE: .github/scripts/setup/terraform-switch-latest.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Install Terraform 1.14.4 .github/scripts/setup/terraform-switch.sh 1.14.4 ================================================ FILE: .github/scripts/setup/terraform-switch.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" if [[ $# -lt 1 ]]; then echo "Usage: $0 " >&2 exit 1 fi TF_VERSION="$1" touch "$ENV_FILE" mise uninstall opentofu mise use "terraform@${TF_VERSION}" terraform --version printf "export TG_TF_PATH='%s'\n" "terraform" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/tofu-switch.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${ENV_FILE:?ENV_FILE is not set}" touch "$ENV_FILE" mise uninstall --all terraform tofu --version printf "export TG_TF_PATH='%s'\n" "tofu" >> "$ENV_FILE" ================================================ FILE: .github/scripts/setup/windows-setup.ps1 ================================================ git config --global core.compression 9 git config --system core.longpaths true git config --global core.longpaths true git config --local core.longpaths true Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" mkdir C:\bin cmd /c mklink C:\bin\sh.exe "C:\Program Files\Git\usr\bin\bash.exe" cmd /c mklink C:\bin\bash.exe "C:\Program Files\Git\usr\bin\bash.exe" echo "C:\bin" | Out-File -Append -FilePath $env:GITHUB_PATH ================================================ FILE: .github/workflows/announce-release.yml ================================================ name: Announce Release on: release: # This is intentionally not `published` to avoid announcing pre-releases types: [released] workflow_dispatch: inputs: tag_name: description: 'The tag name of the release' required: true jobs: release: runs-on: ubuntu-slim steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Announce Release run: ./.github/scripts/announce-release.sh env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} TAG_NAME: ${{ github.event.release.tag_name || inputs.tag_name }} URL: ${{ secrets.RELEASE_ANNOUNCEMENT_URL }} ROLE_ID: ${{ secrets.RELEASE_ANNOUNCEMENT_ROLE_ID }} USERNAME: ${{ secrets.RELEASE_ANNOUNCEMENT_USERNAME }} AVATAR_URL: ${{ secrets.RELEASE_ANNOUNCEMENT_AVATAR_URL }} ================================================ FILE: .github/workflows/base-test.yml ================================================ name: Base Tests on: workflow_call: jobs: test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }}-latest strategy: fail-fast: false matrix: os: [ubuntu, macos] env: MISE_PROFILE: cicd # Reduce GC frequency (default 100) to speed up builds/tests at cost of higher memory GOGC: "400" steps: - name: "Mount tmpfs" shell: bash if: runner.os == 'Linux' run: | sudo mount -t tmpfs -o size=12G tmpfs /tmp mkdir -p /home/runner/go sudo mount -t tmpfs -o size=12G tmpfs /home/runner/go mkdir -p /home/runner/.cache/go-build sudo mount -t tmpfs -o size=4G tmpfs /home/runner/.cache/go-build mkdir -p /home/runner/.cache/terragrunt sudo mount -t tmpfs -o size=4G tmpfs /home/runner/.cache/terragrunt - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 1 # RAM disk for go-build cache — persists through post-steps so cache save works. # If ramdisk is unmounted before job cleanup, cache save will fail silently. - name: "Mount RAM disk" shell: bash if: runner.os == 'macOS' run: | RAMDISK=$(hdiutil attach -nomount ram://8388608 | tr -d '[:space:]') || { echo "Failed to create RAM disk"; exit 1; } # Wait for disk device to be registered in kernel (hdiutil may return before kernel is ready) for i in $(seq 1 10); do diskutil info "$RAMDISK" > /dev/null 2>&1 && break sleep 1 done diskutil erasevolume HFS+ RAMDisk "$RAMDISK" mkdir -p /Volumes/RAMDisk/go-build rm -rf ~/Library/Caches/go-build ln -sf /Volumes/RAMDisk/go-build ~/Library/Caches/go-build - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 restore-keys: | ${{ runner.os }}-go-build- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 restore-keys: | ${{ runner.os }}-go-mod- - name: Terragrunt Provider Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ runner.os == 'Linux' && '~/.cache/terragrunt' || '~/Library/Caches/terragrunt' }} key: ${{ runner.os }}-terragrunt-provider-cache-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terragrunt-provider-cache- - name: Run Tests id: run-tests run: | set -o pipefail go test -v ./... -timeout 45m | tee >(go-junit-report -set-exit-code > result.xml) shell: bash env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use shorter temp path on macOS to avoid path length issues with Git worktrees. # Default /var/folders/.../T/ paths are too long for Git's path resolution. TMPDIR: ${{ matrix.os == 'macos' && '/tmp' || '' }} - name: Upload Report (${{ matrix.os }}) uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-report-${{ matrix.os }} path: result.xml - name: Display Test Results (${{ matrix.os }}) uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6 if: always() with: report_paths: result.xml detailed_summary: 'true' include_time_in_summary: 'true' group_suite: 'true' ================================================ FILE: .github/workflows/build-no-proxy.yml ================================================ name: Build Without Go Proxy on: workflow_call: workflow_dispatch: jobs: build-no-proxy: name: Build (${{ matrix.os }}/${{ matrix.arch }}) runs-on: ubuntu-latest strategy: matrix: include: - os: darwin arch: amd64 - os: darwin arch: arm64 - os: linux arch: "386" - os: linux arch: amd64 - os: linux arch: arm64 - os: windows arch: "386" - os: windows arch: amd64 steps: - name: "Mount tmpfs" shell: bash if: runner.os == 'Linux' run: | sudo mount -t tmpfs -o size=12G tmpfs /tmp mkdir -p /home/runner/go sudo mount -t tmpfs -o size=12G tmpfs /home/runner/go mkdir -p /home/runner/.cache/go-build sudo mount -t tmpfs -o size=4G tmpfs /home/runner/.cache/go-build - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-${{ matrix.arch }} restore-keys: | ${{ runner.os }}-go-build- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- - name: Build Terragrunt without Go proxy env: GOPROXY: direct GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} run: | OUTPUT="bin/terragrunt-${GOOS}-${GOARCH}" if [ "${GOOS}" = "windows" ]; then OUTPUT="${OUTPUT}.exe" fi go build -o "${OUTPUT}" \ -ldflags "-X github.com/gruntwork-io/go-commons/version.Version=${GITHUB_REF_NAME} -extldflags '-static'" \ . ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: workflow_call: jobs: detect_release: name: Detect if this is a release build runs-on: ubuntu-latest outputs: is_release: ${{ steps.check.outputs.is_release }} steps: - name: Check if release tag id: check run: | REF="${GITHUB_REF}" echo "Git ref: $REF" # Check if this is a release tag (v*, alpha*, beta*) if [[ "$REF" =~ ^refs/tags/v.* ]] || \ [[ "$REF" =~ ^refs/tags/alpha.* ]] || \ [[ "$REF" =~ ^refs/tags/beta.* ]]; then echo "is_release=true" >> "$GITHUB_OUTPUT" echo "This is a RELEASE build - signing will be enabled" else echo "is_release=false" >> "$GITHUB_OUTPUT" echo "This is NOT a release build - signing will be skipped" fi build: name: Build (${{ matrix.os }}/${{ matrix.arch }}) needs: detect_release runs-on: ubuntu-latest strategy: matrix: include: - os: darwin arch: amd64 - os: darwin arch: arm64 - os: linux arch: "386" - os: linux arch: amd64 - os: linux arch: arm64 - os: windows arch: "386" - os: windows arch: amd64 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-${{ matrix.arch }} restore-keys: | ${{ runner.os }}-go-build- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- - name: Build Terragrunt env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 run: | OUTPUT="bin/terragrunt_${GOOS}_${GOARCH}" if [[ "${GOOS}" == "windows" ]]; then OUTPUT="${OUTPUT}.exe" fi go build -o "${OUTPUT}" \ -ldflags "-s -w -X github.com/gruntwork-io/go-commons/version.Version=${GITHUB_REF_NAME}" \ . - name: Verify Static Linking env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} run: | OUTPUT="bin/terragrunt_${GOOS}_${GOARCH}" if [[ "${GOOS}" == "windows" ]]; then OUTPUT="${OUTPUT}.exe" fi .github/scripts/release/verify-static-binary.sh "${OUTPUT}" "${GOOS}" "${GOARCH}" - name: Upload Build Artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: terragrunt_${{ matrix.os }}_${{ matrix.arch }} path: bin/terragrunt_${{ matrix.os }}_${{ matrix.arch }}* sign_macos: name: Sign MacOS Binaries if: ${{ needs.detect_release.outputs.is_release == 'true' }} needs: [detect_release, build] uses: ./.github/workflows/sign-macos.yml secrets: inherit sign_windows: name: Sign Windows Binaries if: ${{ needs.detect_release.outputs.is_release == 'true' }} needs: [detect_release, build] uses: ./.github/workflows/sign-windows.yml secrets: inherit merge_signed_binaries: name: Merge All Signed Binaries if: ${{ needs.detect_release.outputs.is_release == 'true' }} needs: [detect_release, sign_macos, sign_windows] runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download macOS signed binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos-signed-files path: bin/ - name: Verify macOS binaries after download run: | # Get list of expected macOS binaries from config macos_binaries=$(jq -r '.platforms[] | select(.os == "darwin") | .binary' .github/assets/release-assets-config.json) echo "Expected macOS binaries from config:" echo "$macos_binaries" echo "" echo "Listing expected files in bin/:" for binary in $macos_binaries; do if [[ -f "bin/$binary" ]]; then ls -lh "bin/$binary" fi if [[ -f "bin/$binary.zip" ]]; then ls -lh "bin/$binary.zip" fi done echo "" echo "Verifying macOS binaries:" for binary in $macos_binaries; do echo "Checking bin/$binary:" [[ -f "bin/$binary" ]] || { echo " ERROR: Binary bin/$binary is missing from downloaded artifact" exit 1 } echo " OK: bin/$binary" file "bin/$binary" # Check for ZIP file if [[ -f "bin/$binary.zip" ]]; then echo " OK: bin/$binary.zip" ls -lh "bin/$binary.zip" else echo " NOTICE: bin/$binary.zip (not present, will be created in merge)" fi echo "" done - name: Download Windows signed binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-signed-files path: bin/ - name: Download Linux binaries (unsigned) uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: terragrunt_linux_* path: bin/ merge-multiple: true - name: List all binaries run: | echo "All binaries ready for release:" ls -lahrt bin/* - name: Upload All Signed Binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: all-signed-binaries path: bin/* ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: lint: uses: ./.github/workflows/lint.yml permissions: contents: read secrets: inherit precommit: uses: ./.github/workflows/precommit.yml permissions: contents: read secrets: inherit codespell: uses: ./.github/workflows/codespell.yml permissions: contents: read secrets: inherit go_mod_tidy_check: uses: ./.github/workflows/go-mod-tidy-check.yml permissions: contents: read secrets: inherit markdownlint: uses: ./.github/workflows/markdownlint.yml permissions: contents: read secrets: inherit install_script_test: uses: ./.github/workflows/install-script-test.yml permissions: contents: read secrets: inherit license_check: uses: ./.github/workflows/license-check.yml permissions: contents: read secrets: inherit fuzz: uses: ./.github/workflows/fuzz.yml permissions: contents: read secrets: inherit # Fast feedback: only gate on lint/precommit/go_mod_tidy. Other checks (codespell, # markdownlint, license_check, install_script_test) run in parallel and are enforced # by branch protection rules, not by job dependencies. base_tests: needs: [lint, precommit, go_mod_tidy_check] uses: ./.github/workflows/base-test.yml permissions: contents: read checks: write secrets: inherit build: needs: [lint, precommit, go_mod_tidy_check] uses: ./.github/workflows/build.yml permissions: contents: read secrets: inherit build_no_proxy: # Only run no_proxy builds on main branch to save CI time if: github.ref == 'refs/heads/main' needs: [lint, precommit, go_mod_tidy_check] uses: ./.github/workflows/build-no-proxy.yml permissions: contents: read secrets: inherit integration_tests: needs: [base_tests, build] uses: ./.github/workflows/integration-test.yml permissions: contents: read checks: write secrets: inherit oidc_integration_tests: needs: [base_tests, build] uses: ./.github/workflows/oidc-integration-test.yml permissions: id-token: write contents: read checks: write secrets: inherit ================================================ FILE: .github/workflows/cloud-nuke.yml ================================================ name: Hourly Cloud Nuke on: schedule: - cron: "0 * * * *" # Runs every hour workflow_dispatch: jobs: run_cloud_nuke: permissions: id-token: write contents: read name: Nuke runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install cloud-nuke run: | wget -O /usr/local/bin/cloud-nuke \ --header="Authorization: Bearer ${GITHUB_TOKEN}" \ "https://github.com/gruntwork-io/cloud-nuke/releases/download/v${VERSION}/cloud-nuke_linux_amd64" chmod +x /usr/local/bin/cloud-nuke env: # Authenticate to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: 0.40.0 - name: Authenticate to AWS uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6 with: role-to-assume: ${{ secrets.CLOUD_NUKE_ROLE }} aws-region: us-east-1 - name: Run cloud-nuke run: | cloud-nuke aws \ --force \ --log-level debug \ --resource-type s3 \ --resource-type vpc \ --resource-type ec2 \ --resource-type dynamodb \ --region us-east-1 \ --region us-west-2 \ --older-than 1h \ --config .github/cloud-nuke/config.yml ================================================ FILE: .github/workflows/codespell.yml ================================================ name: Codespell on: workflow_call: jobs: codespell: name: Check Spelling runs-on: ubuntu-slim env: MISE_PROFILE: cicd steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run codespell run: codespell ================================================ FILE: .github/workflows/flake.yml ================================================ name: Flake on: release: types: [prereleased] workflow_dispatch: jobs: test: name: Flake Test (${{ matrix.flake.name }}) runs-on: ${{ matrix.flake.os }}-latest strategy: fail-fast: false matrix: flake: - name: Ubuntu Base tests os: ubuntu count: 3 timeout: 45m - name: macOS Base tests os: macos count: 3 timeout: 45m env: MISE_PROFILE: cicd steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | ${{ steps.go-cache-paths.outputs.go-build }} ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.flake.os }}-amd64 - name: Run Tests id: run-tests run: | set -o pipefail go test -v ./... -count=${COUNT} -timeout ${TIMEOUT} | tee >(go-junit-report -set-exit-code > result.xml) shell: bash env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COUNT: ${{ matrix.flake.count }} TIMEOUT: ${{ matrix.flake.timeout }} - name: Upload Report (${{ matrix.flake.name }}) uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-report-${{ matrix.flake.name }} path: result.xml - name: Display Test Results (${{ matrix.flake.name }}) uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6 if: always() with: report_paths: result.xml detailed_summary: 'true' include_time_in_summary: 'true' group_suite: 'true' ================================================ FILE: .github/workflows/fuzz.yml ================================================ name: Fuzz on: workflow_call: jobs: fuzz: name: Fuzz Tests runs-on: ubuntu-latest env: MISE_PROFILE: cicd steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 1 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-linux-amd64 restore-keys: | ${{ runner.os }}-go-build- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-linux-amd64 restore-keys: | ${{ runner.os }}-go-mod- - name: Fuzz Corpus Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/go-build/fuzz key: ${{ runner.os }}-fuzz-corpus-${{ github.sha }} restore-keys: | ${{ runner.os }}-fuzz-corpus- - name: Run Fuzz Tests run: make fuzz - name: Archive Fuzz Failures if: failure() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: fuzz-failures path: | **/testdata/fuzz/** ================================================ FILE: .github/workflows/go-mod-tidy-check.yml ================================================ name: Go Mod Tidy Check on: workflow_call: jobs: go-mod-tidy-check: name: Check go.mod and go.sum are tidy runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run go mod tidy run: go mod tidy - name: Check for changes run: | if ! git diff --exit-code go.mod go.sum; then echo "::error::go.mod or go.sum are not tidy. Please run 'go mod tidy' locally and commit the changes." exit 1 fi echo "go.mod and go.sum are tidy" ================================================ FILE: .github/workflows/gopls.yml ================================================ name: Gopls Quickfix Check on: schedule: - cron: '0 2 1 * *' workflow_dispatch: jobs: gopls-quickfix: name: Gopls Quickfix runs-on: ubuntu-latest permissions: contents: write issues: write pull-requests: write steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: ${{ secrets.GITHUB_TOKEN }} ref: main - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MISE_PROFILE: cicd - name: Find Go files id: gofiles run: | find . -type f -name '*.go' -not -path './vendor/*' > gofiles.txt - name: Install parallel run: sudo apt-get update && sudo apt-get install -y parallel - name: Run gopls quickfixes id: gopls_run run: ./.github/scripts/gopls/run.sh - name: Check for changes id: check-changes run: ./.github/scripts/gopls/check-for-changes.sh env: HAS_FIXES: ${{ steps.gopls_run.outputs.has_fixes }} - name: Create issue for problems found id: create_issue_for_problems if: steps.gopls_run.outputs.has_fixes == 'true' uses: actions/github-script@v8 env: FIXED_FILES_PATH: ${{ steps.gopls_run.outputs.fixed_files_path }} OUTPUT_FILE_PATH: ${{ steps.gopls_run.outputs.output_file_path }} with: script: | const createIssue = require('./.github/scripts/gopls/create-issue.js'); const fixedFilesPath = process.env.FIXED_FILES_PATH; const outputFilePath = process.env.OUTPUT_FILE_PATH; await createIssue({ github, context, core, fixedFilesPath, outputFilePath }); - name: Create pull request for fixes if: steps.check-changes.outputs.has_changes == 'true' uses: actions/github-script@v8 env: ISSUE_NUMBER: ${{ steps.create_issue_for_problems.outputs.issue_number }} FIXED_FILES_PATH: ${{ steps.gopls_run.outputs.fixed_files_path }} with: script: | const createPR = require('./.github/scripts/gopls/create-pr.js'); const issueNumber = process.env.ISSUE_NUMBER; const fixedFilesPath = process.env.FIXED_FILES_PATH; await createPR({ github, context, core, exec, issueNumber, fixedFilesPath }); - name: Success message if: steps.gopls_run.outputs.has_fixes == 'false' run: | echo "✅ No gopls quickfix issues found!" echo "All Go files are up to date with gopls recommendations." ================================================ FILE: .github/workflows/install-script-test.yml ================================================ name: Install Script Test on: workflow_call: jobs: install-script-test: name: Install Script Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install GPG (Ubuntu) if: matrix.os == 'ubuntu-latest' run: sudo apt-get update && sudo apt-get install -y gnupg - name: Install Cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4 - name: Run install script tests run: ./docs/tests/install_test.sh ================================================ FILE: .github/workflows/integration-test.yml ================================================ name: Integration Tests on: workflow_call: jobs: test: name: Test (${{ matrix.integration.name }}) runs-on: ${{ matrix.integration.os }}-latest env: MISE_PROFILE: cicd strategy: fail-fast: false matrix: integration: - name: Fixtures with OpenTofu os: ubuntu target: ./test tags: tofu setup_scripts: - .github/scripts/setup/tofu-switch.sh - name: Fixtures with Latest Terraform os: ubuntu target: ./test setup_scripts: - .github/scripts/setup/terraform-switch-latest.sh - name: SSH os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/ssh.sh tags: ssh run: '^TestSSH' secrets: [GHA_DEPLOY_KEY] - name: SOPS os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/sops.sh tags: sops run: '^TestSOPS' - name: Tflint os: ubuntu target: ./... tags: tflint run: '^TestTflint' secrets: [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY] - name: GCP os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/gcp.sh tags: gcp run: '^TestGcp' secrets: [GCLOUD_SERVICE_KEY, GOOGLE_CLOUD_PROJECT, GOOGLE_COMPUTE_ZONE, GOOGLE_IDENTITY_EMAIL, GOOGLE_PROJECT_ID, GCLOUD_SERVICE_KEY_IMPERSONATOR] - name: AWS Tofu os: ubuntu target: ./... tags: 'aws,tofu' run: '^TestAws' secrets: [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_TEST_S3_ASSUME_ROLE] setup_scripts: - .github/scripts/setup/tofu-switch.sh - name: AWS with Latest Terraform os: ubuntu target: ./... tags: aws run: '^TestAws' secrets: [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_TEST_S3_ASSUME_ROLE] setup_scripts: - .github/scripts/setup/terraform-switch-latest.sh - name: AWSGCP os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/gcp.sh tags: awsgcp run: '^TestAwsGcp' secrets: [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, GCLOUD_SERVICE_KEY, GOOGLE_CLOUD_PROJECT, GOOGLE_COMPUTE_ZONE, GOOGLE_IDENTITY_EMAIL, GOOGLE_PROJECT_ID] - name: Engine os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/engine.sh tags: engine run: '^TestEngine' - name: Windows os: windows target: ./... setup_scripts: - .github/scripts/setup/windows-setup.ps1 tags: windows run: '^TestWindows' - name: Provider Cache Server with Latest Terraform os: ubuntu target: ./test setup_scripts: - .github/scripts/setup/provider-cache-server.sh - .github/scripts/setup/terraform-switch-latest.sh - name: Provider Cache Server with Tofu os: ubuntu target: ./test tags: tofu setup_scripts: - .github/scripts/setup/provider-cache-server.sh - .github/scripts/setup/tofu-switch.sh - name: Deprecated os: ubuntu target: ./... tags: deprecated run: '^TestDeprecated' - name: Mock os: ubuntu target: ./... tags: mocks run: '^TestMock' setup_scripts: - .github/scripts/setup/generate-mocks.sh - name: Race os: ubuntu target: ./... run: '.*WithRacing' test_args: "-race" - name: Parse os: ubuntu target: ./... tags: parse run: '^TestParse' - name: CAS os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/cas.sh - name: Experiment mode os: ubuntu target: ./... setup_scripts: - .github/scripts/setup/experiment-mode.sh steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 1 - name: "Setup Docker" if: runner.os == 'Linux' id: set-up-docker uses: docker/setup-docker-action@1a6edb0ba9ac496f6850236981f15d8f9a82254d # v5 - name: "Save space on node" if: runner.os != 'Windows' run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL sudo docker image prune --all --force sudo docker builder prune -a df -h - name: "Mount tmpfs" shell: bash if: runner.os == 'Linux' run: | mkdir -p /home/runner/.cache/go-build sudo mount -t tmpfs -o size=4G tmpfs /home/runner/.cache/go-build # install dependencies for the integration tests - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate Secrets Environment run: ./.github/scripts/setup/generate-secrets.sh env: NAME: ${{ matrix.integration.name }} ENV_FILE: ${{ github.workspace }}/.env.secrets SECRETS: ${{ join(matrix.integration.secrets, ' ') }} GHA_DEPLOY_KEY: ${{ secrets.GHA_DEPLOY_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_TEST_OIDC_ROLE_ARN: ${{ secrets.AWS_TEST_OIDC_ROLE_ARN }} GCLOUD_SERVICE_KEY: ${{ secrets.GCLOUD_SERVICE_KEY }} GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} GOOGLE_COMPUTE_ZONE: ${{ secrets.GOOGLE_COMPUTE_ZONE }} GOOGLE_IDENTITY_EMAIL: ${{ secrets.GOOGLE_IDENTITY_EMAIL }} GOOGLE_PROJECT_ID: ${{ secrets.GOOGLE_PROJECT_ID }} GCLOUD_SERVICE_KEY_IMPERSONATOR: ${{ secrets.GCLOUD_SERVICE_KEY_IMPERSONATOR }} AWS_TEST_S3_ASSUME_ROLE: ${{ secrets.AWS_TEST_S3_ASSUME_ROLE }} shell: bash - name: Setup if: runner.os != 'Windows' run: ./.github/scripts/setup/run-setup-scripts.sh shell: bash env: ENV_FILE: ${{ github.workspace }}/.env.secrets SETUP_SCRIPTS: ${{ join(matrix.integration.setup_scripts, ' ') }} - name: Windows Setup if: runner.os == 'Windows' run: pwsh -File ./.github/scripts/setup/windows-setup.ps1 shell: pwsh env: ENV_FILE: ${{ github.workspace }}/.env.secrets SETUP_SCRIPTS: ${{ join(matrix.integration.setup_scripts, ' ') }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | ${{ steps.go-cache-paths.outputs.go-build }} ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.integration.os }}-amd64 restore-keys: | ${{ runner.os }}-go-build- - name: Terragrunt Provider Cache if: runner.os == 'Linux' uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/terragrunt key: ${{ runner.os }}-terragrunt-provider-cache-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terragrunt-provider-cache- - name: Run Tests run: | if [ "$SKIP" != "true" ]; then source "${GITHUB_WORKSPACE}/.env.secrets" # print command arguments set -x if [[ "$HAS_DOCKER" == "true" ]]; then TAGS="${TAGS:+$TAGS,docker}" TAGS="${TAGS:=docker}" fi go test -v -timeout 45m ${TAGS:+-tags "$TAGS"} ${RUN:+-run "$RUN"} ${TEST_ARGS} "${TARGET}" | tee test_output.log # Generate XML report from test output go-junit-report < test_output.log > result.xml else echo "Skipping tests for $NAME as the skip flag is true." fi shell: bash env: GITHUB_OAUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TARGET: ${{ matrix.integration.target }} TAGS: ${{ matrix.integration.tags }} RUN: ${{ matrix.integration.run }} SKIP: ${{ matrix.integration.skip }} NAME: ${{ matrix.integration.name }} TEST_ARGS: ${{ matrix.integration.test_args }} HAS_DOCKER: ${{ runner.os == 'Linux' }} - name: Upload Test Results (${{ matrix.integration.name }}) uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-results-${{ matrix.integration.name }} path: | test_output.log result.xml - name: Display Test Results (${{ matrix.integration.name }}) uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6 if: always() with: report_paths: result.xml detailed_summary: 'true' include_time_in_summary: 'true' group_suite: 'true' - name: Print Failed Tests (${{ matrix.integration.name }}) run: | echo "Failed Tests in ${{ matrix.integration.name }}" if [[ -f test_output.log ]]; then # Count only test failure lines in a way that is safe with -e -o pipefail failed_count=$(grep -E -c '^--- FAIL:' test_output.log || echo 0) echo "Failed tests count: $failed_count" if [[ "${failed_count}" -gt 0 ]]; then echo "Failed test names:" grep -E '^--- FAIL:' test_output.log | sed 's/.*FAIL:[[:space:]]*//' else echo "No failed tests found" fi else echo "No test output found" fi echo "" shell: bash ================================================ FILE: .github/workflows/license-check.yml ================================================ name: License Check on: workflow_call: jobs: license-check: name: License Check runs-on: ubuntu-slim env: MISE_PROFILE: cicd steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-linux-amd64 restore-keys: | ${{ runner.os }}-go-build- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-linux-amd64 restore-keys: | ${{ runner.os }}-go-mod- - name: Run License Check id: run-license-check run: | set -o pipefail make license-check | tee license-check.log shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload License Check Report uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: license-check-report-ubuntu path: license-check.log ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: workflow_call: jobs: lint: name: Lint (${{ matrix.os }}) runs-on: ${{ matrix.os }}-latest strategy: fail-fast: false matrix: os: [ubuntu, macos] env: # Reduce GC frequency (default 100) to speed up builds/tests at cost of higher memory GOGC: "400" steps: - name: "Mount tmpfs" shell: bash if: runner.os == 'Linux' run: | sudo mount -t tmpfs -o size=12G tmpfs /tmp mkdir -p /home/runner/go sudo mount -t tmpfs -o size=12G tmpfs /home/runner/go mkdir -p /home/runner/.cache/go-build sudo mount -t tmpfs -o size=4G tmpfs /home/runner/.cache/go-build mkdir -p /home/runner/.cache/golangci-lint sudo mount -t tmpfs -o size=2G tmpfs /home/runner/.cache/golangci-lint - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up mise uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths shell: bash run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" if [ "$RUNNER_OS" == "macOS" ]; then echo "golangci-lint-cache=$HOME/Library/Caches/golangci-lint" >> "$GITHUB_OUTPUT" exit 0 fi echo "golangci-lint-cache=$HOME/.cache/golangci-lint" >> "$GITHUB_OUTPUT" - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 restore-keys: | ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}- - name: Go Mod Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 restore-keys: | ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}- ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}- ${{ runner.os }}-go-mod- - name: golangci-lint Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ${{ steps.go-cache-paths.outputs.golangci-lint-cache }} key: ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}-amd64 restore-keys: | ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }}-${{ matrix.os }}- ${{ runner.os }}-golangci-lint- # fetch-depth: 0 fetches current branch history; origin/main ref must be fetched separately. # Use refspec main:main to create a local branch (required for git merge-base main HEAD). - name: Fetch main branch if: startsWith(github.ref, 'refs/heads/') && github.ref != 'refs/heads/main' run: git fetch origin main:main # Ensure Go build cache directory exists on macOS. # Prevents "mkdir go-build: file exists" or "No such file or directory" # errors in golangci-lint. - name: Ensure Go build cache dir (macOS) if: runner.os == 'macOS' run: | cache_dir="$(go env GOCACHE)" if [ -f "$cache_dir" ]; then rm -f "$cache_dir"; fi mkdir -p "$cache_dir" # macOS runners have ~14GB RAM. GOGC=400 lets the Go heap grow to ~31GB (measured), causing # heavy swap and lint timeout. Cap heap to keep golangci-lint within available physical memory. - name: Set memory limits (macOS) if: runner.os == 'macOS' run: | echo "GOGC=100" >> "$GITHUB_ENV" echo "GOMEMLIMIT=10GiB" >> "$GITHUB_ENV" # Linux runners have ~28GB RAM but GOGC=400 still causes the heap to grow beyond available # physical memory when linting with many build tags, triggering runner shutdown signals. # Cap the heap to keep golangci-lint stable on Linux. - name: Set memory limits (Linux) if: runner.os == 'Linux' run: | echo "GOGC=100" >> "$GITHUB_ENV" echo "GOMEMLIMIT=20GiB" >> "$GITHUB_ENV" - name: Check for lint config changes id: lint-config if: startsWith(github.ref, 'refs/heads/') && github.ref != 'refs/heads/main' run: | if git diff --name-only origin/main...HEAD | grep -q '^\.golangci\.yml$'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi - name: Lint run: | # Full lint on main, tags, or when lint config changed if [[ "${{ github.ref }}" != refs/heads/* ]] || \ [[ "${{ github.ref }}" == "refs/heads/main" ]] || \ [[ "${{ steps.lint-config.outputs.changed }}" == "true" ]]; then make run-lint else make run-lint-incremental fi ================================================ FILE: .github/workflows/markdownlint.yml ================================================ name: Markdown Lint on: workflow_call: jobs: markdownlint: name: Run Lint runs-on: ubuntu-slim steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run markdownlint uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 # v22 with: globs: | docs/**/*.md ================================================ FILE: .github/workflows/oidc-integration-test.yml ================================================ # These tests require different top-level permissions # than the other integration tests, so we're keeping them # in a separate workflow. name: OIDC Integration Tests on: workflow_call: jobs: test: permissions: id-token: write contents: read checks: write name: Test OIDC (${{ matrix.integration.name }}) runs-on: ${{ matrix.integration.os }}-latest env: MISE_PROFILE: cicd strategy: fail-fast: false matrix: integration: - name: GHA AWS os: ubuntu target: ./... tags: awsoidc run: '^TestAws' # We leave the key and secret on so that cleanup steps can use them. secrets: [AWS_TEST_OIDC_ROLE_ARN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY] setup_scripts: - .github/scripts/setup/tofu-switch.sh steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: "Save space on node" if: runner.os != 'Windows' run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL sudo docker image prune --all --force sudo docker builder prune -a df -h # install dependencies for the integration tests - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate Secrets Environment run: ./.github/scripts/setup/generate-secrets.sh env: NAME: ${{ matrix.integration.name }} ENV_FILE: ${{ github.workspace }}/.env.secrets SECRETS: ${{ join(matrix.integration.secrets, ' ') }} GHA_DEPLOY_KEY: ${{ secrets.GHA_DEPLOY_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_TEST_OIDC_ROLE_ARN: ${{ secrets.AWS_TEST_OIDC_ROLE_ARN }} GCLOUD_SERVICE_KEY: ${{ secrets.GCLOUD_SERVICE_KEY }} GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} GOOGLE_COMPUTE_ZONE: ${{ secrets.GOOGLE_COMPUTE_ZONE }} GOOGLE_IDENTITY_EMAIL: ${{ secrets.GOOGLE_IDENTITY_EMAIL }} GOOGLE_PROJECT_ID: ${{ secrets.GOOGLE_PROJECT_ID }} GCLOUD_SERVICE_KEY_IMPERSONATOR: ${{ secrets.GCLOUD_SERVICE_KEY_IMPERSONATOR }} AWS_TEST_S3_ASSUME_ROLE: ${{ secrets.AWS_TEST_S3_ASSUME_ROLE }} shell: bash - name: Setup if: runner.os != 'Windows' run: ./.github/scripts/setup/run-setup-scripts.sh shell: bash env: ENV_FILE: ${{ github.workspace }}/.env.secrets SETUP_SCRIPTS: ${{ join(matrix.integration.setup_scripts, ' ') }} - name: Windows Setup if: runner.os == 'Windows' run: pwsh -File ./.github/scripts/setup/windows-setup.ps1 shell: pwsh env: ENV_FILE: ${{ github.workspace }}/.env.secrets SETUP_SCRIPTS: ${{ join(matrix.integration.setup_scripts, ' ') }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" shell: bash - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | ${{ steps.go-cache-paths.outputs.go-build }} ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-${{ matrix.integration.os }}-amd64 - name: Terragrunt Provider Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/terragrunt key: ${{ runner.os }}-terragrunt-provider-cache-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terragrunt-provider-cache- - name: Run Tests run: | if [ "$SKIP" != "true" ]; then source "${GITHUB_WORKSPACE}/.env.secrets" # print command arguments set -x go test -v -timeout 45m ${TAGS:+-tags "$TAGS"} ${RUN:+-run "$RUN"} ${TEST_ARGS} "${TARGET}" | tee >(go-junit-report -set-exit-code > result.xml) else echo "Skipping tests for $NAME as the skip flag is true." fi shell: bash env: GITHUB_OAUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TARGET: ${{ matrix.integration.target }} TAGS: ${{ matrix.integration.tags }} RUN: ${{ matrix.integration.run }} SKIP: ${{ matrix.integration.skip }} NAME: ${{ matrix.integration.name }} TEST_ARGS: ${{ matrix.integration.test_args }} - name: Upload Report (${{ matrix.integration.name }}) uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: test-report-${{ matrix.integration.name }} path: result.xml - name: Display Test Results (${{ matrix.integration.name }}) uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6 if: always() with: report_paths: result.xml detailed_summary: 'true' include_time_in_summary: 'true' group_suite: 'true' ================================================ FILE: .github/workflows/precommit.yml ================================================ name: Pre-commit on: workflow_call: jobs: precommit: name: Run pre-commit hooks runs-on: ubuntu-latest env: MISE_PROFILE: cicd steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: # Adding token here to reduce the likelihood of hitting rate limit issues. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - id: go-cache-paths run: | echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" - name: Go Build Cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | ${{ steps.go-cache-paths.outputs.go-build }} ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}-linux-amd64 restore-keys: | ${{ runner.os }}-go-build- - name: Run pre-commit hooks env: GOPROXY: direct GOOS: linux GOARCH: amd64 run: | pre-commit install pre-commit run --all-files ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' - 'alpha*' - 'beta*' workflow_dispatch: inputs: tag: description: 'Tag to release (e.g., v0.58.8)' required: true type: string clobber: description: 'Overwrite existing release assets (--clobber)' required: false type: boolean default: false jobs: # Build and sign all binaries (reuses build.yml workflow) build-and-sign: name: Build and Sign All Binaries uses: ./.github/workflows/build.yml permissions: contents: write id-token: write actions: read secrets: inherit # Upload binaries to existing GitHub release upload-assets: name: Upload Release Assets needs: build-and-sign runs-on: ubuntu-latest permissions: contents: write id-token: write actions: read steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Get version id: version env: INPUT_TAG: ${{ inputs.tag }} EVENT_NAME: ${{ github.event_name }} run: .github/scripts/release/get-version.sh - name: Check if release exists id: check_release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: .github/scripts/release/check-release-exists.sh - name: Download pre-built signed binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: all-signed-binaries path: bin/ - name: Verify binaries downloaded run: .github/scripts/release/verify-binaries-downloaded.sh bin 7 - name: Set execution permissions on binaries run: .github/scripts/release/set-permissions.sh bin - name: Create ZIP and TAR.GZ archives run: .github/scripts/release/create-archives.sh bin - name: Generate SHA256SUMS run: .github/scripts/release/generate-checksums.sh bin - name: Import GPG key and export public key env: SIGNING_GPG_PRIVATE_KEY: ${{ secrets.SIGNING_GPG_PRIVATE_KEY }} run: | echo "${SIGNING_GPG_PRIVATE_KEY}" | base64 --decode | gpg --batch --import GPG_FINGERPRINT=$(gpg --list-secret-keys --keyid-format LONG | awk '/^sec/{sub(/.*\//, "", $2); print $2; exit}') echo "GPG_FINGERPRINT=${GPG_FINGERPRINT}" >> "${GITHUB_ENV}" gpg --armor --export "${GPG_FINGERPRINT}" > bin/terragrunt-signing-key.asc - name: Install Cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4 - name: Sign SHA256SUMS env: SIGNING_GPG_PASSPHRASE: ${{ secrets.SIGNING_GPG_PASSPHRASE }} run: .github/scripts/release/sign-checksums.sh bin - name: Verify signatures before upload run: .github/scripts/release/verify-files.sh bin - name: Upload assets to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} CLOBBER: ${{ github.event_name == 'workflow_dispatch' && inputs.clobber || 'false' }} run: .github/scripts/release/upload-assets.sh bin - name: Verify all assets uploaded env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} CLOBBER: ${{ github.event_name == 'workflow_dispatch' && inputs.clobber || 'false' }} run: .github/scripts/release/verify-assets-uploaded.sh bin - name: Upload summary if: always() env: VERSION: ${{ steps.version.outputs.version }} RELEASE_ID: ${{ steps.check_release.outputs.release_id }} IS_DRAFT: ${{ steps.check_release.outputs.is_draft }} run: .github/scripts/release/generate-upload-summary.sh ================================================ FILE: .github/workflows/sign-macos.yml ================================================ name: Sign MacOS Binaries on: workflow_dispatch: inputs: artifact-pattern: description: 'Pattern for artifacts to download (default: terragrunt_darwin_*)' required: false type: string default: 'terragrunt_darwin_*' upload-artifact-name: description: 'Name for the uploaded signed artifacts' required: false type: string default: 'macos-signed-files' workflow_call: inputs: artifact-pattern: description: 'Pattern for artifacts to download (default: terragrunt_darwin_*)' required: false type: string default: 'terragrunt_darwin_*' upload-artifact-name: description: 'Name for the uploaded signed artifacts' required: false type: string default: 'macos-signed-files' jobs: sign-macos: name: Sign MacOS Binaries runs-on: macos-latest env: MISE_PROFILE: cicd GON_VERSION: v0.0.37 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download macOS build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ${{ inputs.artifact-pattern }} path: artifacts/ - name: Prepare build artifacts run: .github/scripts/release/prepare-macos-artifacts.sh artifacts bin - name: Use mise to install dependencies uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: version: 2026.1.9 experimental: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Cache gon binary id: cache-gon uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: gon key: gon-${{ env.GON_VERSION }} - name: Download and install gon if: steps.cache-gon.outputs.cache-hit != 'true' run: .github/scripts/release/install-gon.sh ${{ env.GON_VERSION }} - name: Verify gon installation run: gon --version - name: Sign MacOS Binaries env: AC_PASSWORD: ${{ secrets.MACOS_AC_PASSWORD }} AC_PROVIDER: ${{ secrets.MACOS_AC_PROVIDER }} AC_USERNAME: ${{ secrets.MACOS_AC_LOGIN }} MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: .github/scripts/release/sign-macos-binaries.sh bin - name: Verify codesign signatures run: | # Get list of expected macOS binaries from config macos_binaries=$(jq -r '.platforms[] | select(.os == "darwin") | .binary' .github/assets/release-assets-config.json) for binary in $macos_binaries; do codesign -dv --verbose=4 "bin/$binary" 2>&1 || { echo "ERROR: No valid signature found for bin/$binary" exit 1 } done - name: Upload Signed MacOS Binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.upload-artifact-name }} path: bin/terragrunt_darwin_* if-no-files-found: error ================================================ FILE: .github/workflows/sign-windows.yml ================================================ name: Sign Windows Binaries on: workflow_call: inputs: artifact_pattern: description: 'Pattern for artifacts to download (default: terragrunt_windows_*)' required: false type: string default: 'terragrunt_windows_*' upload_artifact_name: description: 'Name for the uploaded signed artifacts' required: false type: string default: 'windows-signed-files' jobs: sign-windows: name: Sign Windows Binaries runs-on: windows-latest env: SM_HOST: https://clientauth.one.digicert.com SM_API_KEY: ${{ secrets.WINDOWS_SIGNING_API_KEY }} SM_CLIENT_CERT_PASSWORD: ${{ secrets.WINDOWS_SIGNING_P12_PASSWORD }} SM_KEYPAIR_ALIAS: ${{ secrets.WINDOWS_SIGNING_KEYPAIR_ALIAS }} WINDOWS_SIGNING_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_P12_BASE64 }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download Windows build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ${{ inputs.artifact_pattern }} path: artifacts/ merge-multiple: true - name: Prepare build artifacts shell: pwsh run: .github/scripts/release/prepare-windows-artifacts.ps1 -ArtifactsDirectory artifacts -BinDirectory bin - name: Setup Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: go.mod - name: Install go-winres shell: pwsh run: .github/scripts/release/install-go-winres.ps1 # Install DigiCert smtools (smctl) - name: Install DigiCert smtools uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 with: force-download-tools: 'true' # Verify smctl is available - name: Verify smctl installation shell: pwsh run: .github/scripts/release/verify-smctl.ps1 # Restore P12 client certificate and set SM_CLIENT_CERT_FILE - name: Restore P12 client certificate shell: pwsh run: .github/scripts/release/restore-p12-certificate.ps1 # Sign Windows binaries using external script - name: Sign and patch Windows binaries shell: pwsh run: .github/scripts/release/sign-windows.ps1 -BinDirectory bin # Upload Windows binaries (signed amd64 + unsigned 386) - name: Upload Windows Binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ inputs.upload_artifact_name }} path: | bin/terragrunt_windows_amd64.exe bin/terragrunt_windows_386.exe if-no-files-found: error ================================================ FILE: .github/workflows/stale.yml ================================================ name: 'Close stale issues and PRs' on: schedule: - cron: '30 1 * * *' jobs: stale: permissions: contents: read issues: write pull-requests: write runs-on: ubuntu-slim steps: - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for raising this issue.' stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for submitting this pull request.' days-before-stale: 90 days-before-close: 7 stale-issue-label: 'stale' stale-pr-label: 'stale' exempt-issue-labels: 'rfc,preserved' exempt-draft-pr: true ================================================ FILE: .github/workflows/update-codified-remote-deps.yml ================================================ name: Update Codified Remote Dependencies on: schedule: - cron: '0 9 * * 1' # Every Monday at 9:00 UTC workflow_dispatch: jobs: live-stacks-example: permissions: contents: write pull-requests: write name: Live Stacks Example Commit runs-on: ubuntu-slim steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Check for update id: check run: | LATEST_COMMIT=$(git ls-remote https://github.com/gruntwork-io/terragrunt-infrastructure-live-stacks-example.git HEAD | awk '{print $1}') CURRENT_COMMIT=$(grep -oP 'git", "checkout", "\K[0-9a-f]{40}' test/integration_example_live_stacks_test.go) echo "latest=$LATEST_COMMIT" >> "$GITHUB_OUTPUT" echo "current=$CURRENT_COMMIT" >> "$GITHUB_OUTPUT" if [[ "$LATEST_COMMIT" == "$CURRENT_COMMIT" ]]; then echo "needs_update=false" >> "$GITHUB_OUTPUT" else echo "needs_update=true" >> "$GITHUB_OUTPUT" fi - name: Update commit hash if: steps.check.outputs.needs_update == 'true' env: CURRENT_COMMIT: ${{ steps.check.outputs.current }} LATEST_COMMIT: ${{ steps.check.outputs.latest }} run: | sed -i "s/$CURRENT_COMMIT/$LATEST_COMMIT/" test/integration_example_live_stacks_test.go - name: Create pull request if: steps.check.outputs.needs_update == 'true' uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 with: branch: chore/update-live-stacks-example-commit commit-message: 'chore: Update live stacks example commit to ${{ steps.check.outputs.latest }}' title: 'chore: Update live stacks example commit' body: | Updates the pinned commit in `TestExampleLiveStacks` from: - `${{ steps.check.outputs.current }}` to: - `${{ steps.check.outputs.latest }}` [Compare changes](https://github.com/gruntwork-io/terragrunt-infrastructure-live-stacks-example/compare/${{ steps.check.outputs.current }}...${{ steps.check.outputs.latest }}) labels: automated ================================================ FILE: .gitignore ================================================ .*.sw? .idea terragrunt.iml vendor .terraform .vscode *.tfstate *.tfstate.backup *.out .terragrunt-cache .bundle .ruby-version terragrunt .DS_Store .go-version .terragrunt-stack .devcontainer.json .cursor/ .env .licensei.cache bin # Windows code signing resources rsrc.syso resource.syso *.syso ================================================ FILE: .golangci.yml ================================================ version: "2" run: go: "1.26" issues-exit-code: 1 tests: true output: formats: text: path: stdout print-linter-name: true print-issued-lines: true linters: enable: - asasalint - asciicheck - bidichk - bodyclose - contextcheck - dupl - durationcheck - errchkjson - errorlint - exhaustive - fatcontext - gocheckcompilerdirectives - gochecksumtype - goconst - gocritic - gosmopolitan - lll - loggercheck - makezero - misspell - mnd - musttag - nilerr - nilnesserr - noctx - paralleltest - perfsprint - prealloc - protogetter - reassign - rowserrcheck - spancheck - sqlclosecheck - staticcheck - testableexamples - testifylint - testpackage - thelper - tparallel - unconvert - unparam - usetesting - wastedassign - wsl_v5 - zerologlint settings: dupl: threshold: 120 errcheck: check-type-assertions: false check-blank: false exclude-functions: - (*os.File).Close errorlint: errorf: true asserts: true comparison: true goconst: min-len: 3 min-occurrences: 5 gocritic: enabled-tags: - performance disabled-tags: - experimental govet: enable: - fieldalignment - printf - unusedwrite nakedret: max-func-lines: 20 staticcheck: checks: - all - -SA9005 - -QF1008 - -ST1001 unparam: check-exported: false wsl_v5: allow-whole-block: false branch-max-lines: 2 exclusions: generated: lax rules: - linters: - dupl - errcheck - gocyclo - mnd - unparam - wsl path: _test\.go # We end up with duplicated content in this package to save us from duplicating code in other packages. - linters: - dupl path: cli/flags/shared # Incrementally linting lines that are too long to ensure that # we don't have conflicts on every file in the codebase while # trying to get this merged in. - linters: - lll path-except: '^(internal/awshelper/|internal/cas/)' paths: - docs - _ci - .github - .circleci - third_party$ - builtin$ - examples$ issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - goimports settings: gofmt: simplify: true exclusions: generated: lax paths: - docs - _ci - .github - .circleci - third_party$ - builtin$ - examples$ ================================================ FILE: .gon_amd64.hcl ================================================ # See https://github.com/gruntwork-io/terraform-aws-ci/blob/main/modules/sign-binary-helpers/ # for further instructions on how to sign the binary + submitting for notarization. source = ["./bin/terragrunt_darwin_amd64"] bundle_id = "io.gruntwork.app.terragrunt" apple_id { username = "machine.apple@gruntwork.io" } sign { application_identity = "Developer ID Application: Gruntwork, Inc." } zip { output_path = "terragrunt_darwin_amd64.zip" } ================================================ FILE: .gon_arm64.hcl ================================================ # See https://github.com/gruntwork-io/terraform-aws-ci/blob/main/modules/sign-binary-helpers/ # for further instructions on how to sign the binary + submitting for notarization. source = ["./bin/terragrunt_darwin_arm64"] bundle_id = "io.gruntwork.app.terragrunt" apple_id { username = "machine.apple@gruntwork.io" } sign { application_identity = "Developer ID Application: Gruntwork, Inc." } zip { output_path = "terragrunt_darwin_arm64.zip" } ================================================ FILE: .licensei.toml ================================================ approved = [ "apache-2.0", "bsd-2-clause", "bsd-3-clause", "isc", "mpl-2.0", "mit", ] ignored = [ "github.com/terraform-linters/tflint-plugin-sdk", "github.com/owenrumney/go-sarif", "github.com/davecgh/go-spew" ] [header] ignorePaths = ["vendor", ".gen"] ignoreFiles = ["mock_*.go", "*_gen.go"] ================================================ FILE: .markdownlint-cli2.yaml ================================================ config: # Disable line length limit MD013: false # Disable multiple headers with the same content MD024: false # Disable requirement for descriptive links (e.g. allow click [here]()) MD059: false ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/gruntwork-io/pre-commit rev: v0.1.29 hooks: - id: tofu-fmt exclude: test/fixtures/hclvalidate/valid/.* - id: goimports ================================================ FILE: .sonarcloud.properties ================================================ # Source File Exclusions: Patterns used to exclude some source files from analysis. sonar.exclusions=**/*_test.go # Test File Inclusions: Patterns used to include some test files and only these ones in analysis. sonar.test.inclusions=**/*_test.go ================================================ FILE: CODEOWNERS ================================================ * @denis256 @thisguycodes @yhakbar ================================================ FILE: KEYS ================================================ -----BEGIN PGP PUBLIC KEY BLOCK----- mDMEaWUgtBYJKwYBBAHaRw8BAQdA9b1hTzoHHbAYEqd4F+8hBnuw89vQ35F5gaWE 9Tpns760NEdydW50d29yayAoQ29kZSBTaWduaW5nIEtleSkgPHNlY3VyaXR5QGdy dW50d29yay5pbz6IkwQTFgoAOxYhBGjID4bfmOcQwPIuLld3dKyoR8xJBQJpZSC0 AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEFd3dKyoR8xJN9ABAKHD 87thKPV4afl81OA+R+Fqr9x2eFI7EygeWec3b2pUAPwNV6sfkzzPARTKzsZeqcxW vDJAtK5LYaokTLdsXb8bBA== =6QIi -----END PGP PUBLIC KEY BLOCK----- ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2016 Gruntwork, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ GOFMT_FILES?=$$(find . -name '*.go' | grep -v vendor) help: @echo "Various utilities for managing the terragrunt repository" fmt: @echo "Running source files through gofmt..." gofmt -w $(GOFMT_FILES) fmtcheck: pre-commit run goimports --all-files install-pre-commit-hook: pre-commit install # This build target just for convenience for those building directly from # source. See also: .github/workflows/build.yml build: terragrunt terragrunt: $(shell find . \( -type d -name 'vendor' -prune \) \ -o \( -type f -name '*.go' -print \) ) set -xe ;\ vtag_maybe_extra=$$(git describe --tags --abbrev=12 --dirty --broken) ;\ CGO_ENABLED=0 go build -o $@ -ldflags "-s -w -X github.com/gruntwork-io/go-commons/version.Version=$${vtag_maybe_extra}" . clean: rm -f terragrunt IGNORE_TAGS := windows|linux|darwin|freebsd|openbsd|netbsd|dragonfly|solaris|plan9|js|wasip1|aix|android|illumos|ios|386|amd64|arm|arm64|mips|mips64|mips64le|mipsle|ppc64|ppc64le|riscv64|s390x|wasm LINT_TAGS := $(shell grep -rh --include='*.go' 'go:build' . | \ sed 's/.*go:build\s*//' | \ tr -cs '[:alnum:]_' '\n' | \ grep -vE '^($(IGNORE_TAGS))$$' | \ sed '/^$$/d' | \ sort -u | \ paste -sd, -) run-lint: @echo "Linting with feature flags: [$(LINT_TAGS)]" GOFLAGS="-tags=$(LINT_TAGS)" golangci-lint run -v --timeout=30m ./... run-lint-incremental: @echo "Incremental lint (new issues only) with feature flags: [$(LINT_TAGS)]" GOFLAGS="-tags=$(LINT_TAGS)" golangci-lint run -v --timeout=30m --new-from-merge-base=main ./... run-lint-fix: @echo "Linting with feature flags: [$(LINT_TAGS)]" GOFLAGS="-tags=$(LINT_TAGS)" golangci-lint run -v --timeout=30m --fix ./... generate-mocks: go generate ./... license-check: go mod vendor licensei cache --debug licensei check --debug licensei header --debug fuzz: @for package in $$(go list ./...); do \ for fuzz_test in $$(go test -list 'Fuzz' "$$package" 2>/dev/null | grep '^Fuzz' || true); do \ echo "Fuzzing $$fuzz_test in $$package"; \ go test -run '^$$' -fuzztime="30s" -v -fuzz "^$$fuzz_test$$" "$$package"; \ done; \ done .PHONY: help fmt fmtcheck install-pre-commit-hook clean run-lint run-lint-fix fuzz ================================================ FILE: README.md ================================================ # Terragrunt [![Maintained by Gruntwork.io](https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg)](https://gruntwork.io/?ref=repo_terragrunt) [![Go Report Card](https://goreportcard.com/badge/github.com/gruntwork-io/terragrunt)](https://goreportcard.com/report/github.com/gruntwork-io/terragrunt) [![GoDoc](https://godoc.org/github.com/gruntwork-io/terragrunt?status.svg)](https://godoc.org/github.com/gruntwork-io/terragrunt) ![OpenTofu Version](https://img.shields.io/badge/tofu-%3E%3D1.6.0-blue.svg) ![Terraform Version](https://img.shields.io/badge/tf-%3E%3D0.12.0-blue.svg) Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in [OpenTofu](https://opentofu.org)/[Terraform](https://www.terraform.io) to scale. Please see the following for more info, including install instructions and complete documentation: * [Terragrunt Website](https://terragrunt.com) * [Getting started with Terragrunt](https://docs.terragrunt.com/getting-started/quick-start/) * [Terragrunt Documentation](https://docs.terragrunt.com/) * [Contributing to Terragrunt](https://docs.terragrunt.com/community/contributing) * [Commercial Support](https://gruntwork.io/support/) ## Join the Discord! Join [our community](https://discord.com/invite/YENaT9h8jh) for discussions, support, and contributions: [![](https://dcbadge.limes.pink/api/server/https://discord.com/invite/YENaT9h8jh)](https://discord.com/invite/YENaT9h8jh) ## License This code is released under the MIT License. See [LICENSE.txt](LICENSE.txt). ================================================ FILE: SECURITY.md ================================================ # Reporting Security Issues Gruntwork takes security seriously, and we value the input of independent security researchers. If you're reading this because you're looking to engage in responsible disclosure of a security vulnerability, we want to start with thanking you for your efforts. We appreciate your work and will make every effort to acknowledge your contributions. To report a security issue, please use the GitHub Security Advisory ["Report a vulnerability"](https://github.com/gruntwork-io/terragrunt/security/advisories/new) button in the ["Security"](https://github.com/gruntwork-io/terragrunt/security) tab. After receiving the report, we will investigate the issue and inform you of next steps. After the initial reply, we may ask for additional information, and will endeavor to keep you informed of our progress. If you are reporting a bug related to an associated tool that Terragrunt integrates with, we ask that you report the issue directly to the maintainers of that tool. Please do not disclose the issue publicly until we have had a chance to address it. ## Expectations on timelines You can expect that Gruntwork will take any report of a security vulnerability seriously, but we ask that you also respect that it can take time to investigate and address issues given the size of the team maintaining Terragrunt. We will do our best to keep you informed of our progress, and provide insight into the timeline for addressing the issue. ## Thank you We appreciate your help in making Terragrunt more secure. Thank you for your efforts in responsibly disclosing security issues, and for your patience as we work to address them. ## Verifying Release Signatures All Terragrunt releases are signed with both GPG and Cosign. You can verify the authenticity of downloaded binaries using either method. ### Download Verification Files ```bash VERSION="v0.XX.X" # Replace with actual version curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/${VERSION}/SHA256SUMS" curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/${VERSION}/SHA256SUMS.gpgsig" curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/${VERSION}/SHA256SUMS.sig" curl -LO "https://github.com/gruntwork-io/terragrunt/releases/download/${VERSION}/SHA256SUMS.pem" ``` ### GPG Verification ```bash # Import the public key (first time only) curl -s https://gruntwork.io/.well-known/pgp-key.txt | gpg --import # Verify the signature gpg --verify SHA256SUMS.gpgsig SHA256SUMS # Verify binary checksum sha256sum -c SHA256SUMS --ignore-missing ``` ### Cosign Verification ```bash # Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/ cosign verify-blob SHA256SUMS \ --signature SHA256SUMS.sig \ --certificate SHA256SUMS.pem \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp "github.com/gruntwork-io/terragrunt" # Verify binary checksum sha256sum -c SHA256SUMS --ignore-missing ``` ================================================ FILE: docs/.gitignore ================================================ # build output dist/ # generated types .astro/ # dependencies node_modules/ # logs npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # environment variables .env .env.production # macOS-specific files .DS_Store # Vercel files .vercel ================================================ FILE: docs/.vercelignore ================================================ node_modules .env .env.local .env.production ================================================ FILE: docs/README.md ================================================ # Terragrunt Documentation This is the documentation for Terragrunt (hosted at ), built using [Starlight](https://github.com/withastro/starlight), a documentation framework for Astro. ## Development To get started, install the requisite dependencies to run the project locally using [mise](https://mise.jdx.dev/): ```bash mise install ``` Afterwards, you'll want to install the NPM dependencies for the project: ```bash bun i ``` You'll also need to install [d2](https://github.com/terrastruct/d2/blob/master/docs/INSTALL.md) to build any diagrams referenced in the documentation: You can now start the development server: ```bash bun dev ``` This will start a development server on that will be automatically reloaded when you make changes to documentation. ## Building When the project is ready to deployed, it will be built using the following command: ```bash bun run build ``` This will generate a `dist` directory with the built documentation. Running this locally can be useful if you see that the build fails in CI, as additional checks are performed in the build process, like ensuring that all links are valid. ## Hosting The website is hosted on [Vercel](https://vercel.com/), and is automatically deployed when a new commit is pushed to the `main` branch. Every pull request will result in a preview deployment of the documentation site. This preview site is only accessible by maintainers of the project to prevent running untrusted code in Vercel builds. ================================================ FILE: docs/astro.config.mjs ================================================ // @ts-check import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import sitemap from "@astrojs/sitemap"; import vercel from "@astrojs/vercel"; import partytown from "@astrojs/partytown"; import tailwindcss from "@tailwindcss/vite"; import react from "@astrojs/react"; import starlightLinksValidator from "starlight-links-validator"; import starlightLlmsTxt from "starlight-llms-txt"; import d2 from "astro-d2"; // Check if we're in Vercel environment const isVercel = globalThis.process?.env?.VERCEL; export const sidebar = [ { label: "Getting Started", autogenerate: { directory: "01-getting-started" }, }, { label: "Guides", items: [ { label: "Terralith to Terragrunt", autogenerate: { directory: "02-guides/01-terralith-to-terragrunt", collapsed: true }, }, ], collapsed: true, }, { label: "Features", collapsed: true, items: [ { label: "Units", collapsed: true, autogenerate: { directory: "03-features/01-units", collapsed: true }, }, { label: "Stacks", collapsed: true, autogenerate: { directory: "03-features/02-stacks", collapsed: true }, }, { label: "Catalog", collapsed: true, autogenerate: { directory: "03-features/06-catalog", collapsed: true }, }, { label: "Caching", collapsed: true, autogenerate: { directory: "03-features/07-caching", collapsed: true }, }, { label: "Filters", collapsed: true, autogenerate: { directory: "03-features/08-filter", collapsed: true }, }, ], }, { label: "Reference", collapsed: true, items: [ { label: "HCL", autogenerate: { directory: "04-reference/01-hcl", collapsed: true }, }, { label: "CLI", collapsed: true, items: [ { label: "Overview", slug: "reference/cli" }, { label: "Commands", autogenerate: { directory: "04-reference/02-cli/02-commands", collapsed: true, }, }, { label: "Global Flags", slug: "reference/cli/global-flags" }, ], }, { label: "Strict Controls", slug: "reference/strict-controls" }, { label: "Experiments", slug: "reference/experiments" }, { label: "Supported Versions", slug: "reference/supported-versions", }, { label: "Lock Files", slug: "reference/lock-files" }, { label: "Logging", autogenerate: { directory: "04-reference/07-logging", collapsed: true }, }, { label: "Terragrunt Cache", slug: "reference/terragrunt-cache" }, ], }, { label: "Community", autogenerate: { directory: "05-community", collapsed: true }, collapsed: true, }, { label: "Troubleshooting", autogenerate: { directory: "06-troubleshooting", collapsed: true }, collapsed: true, }, { label: "Process", autogenerate: { directory: "07-process", collapsed: true }, collapsed: true, }, { label: "Migrate", autogenerate: { directory: "08-migrate", collapsed: true }, collapsed: true, }, ]; // https://astro.build/config export default defineConfig({ site: "https://docs.terragrunt.com", base: "/", output: isVercel ? "server" : "static", adapter: isVercel ? vercel({ imageService: true, isr: { expiration: 60 * 60 * 24, // 24 hours }, }) : undefined, integrations: [ // We use React for the shadcn/ui components. react(), starlight({ title: "Terragrunt", description: "Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.", editLink: { // TODO: update this once the docs live in `docs`. baseUrl: "https://github.com/gruntwork-io/terragrunt/edit/main/docs", }, customCss: ["./src/styles/global.css"], head: [ { tag: 'meta', attrs: { name: 'description', content: 'Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.', }, }, { tag: 'meta', attrs: { property: 'og:title', content: 'Terragrunt', }, }, { tag: 'meta', attrs: { property: 'og:description', content: 'Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.', }, }, { tag: 'meta', attrs: { property: 'og:type', content: 'website', }, }, { tag: 'meta', attrs: { property: 'og:url', content: 'https://docs.terragrunt.com', }, }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image', }, }, { tag: 'meta', attrs: { name: 'twitter:title', content: 'Terragrunt', }, }, { tag: 'meta', attrs: { name: 'twitter:description', content: 'Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.', }, }, ], components: { Header: "./src/components/Header.astro", PageSidebar: "./src/components/PageSidebar.astro", SiteTitle: "./src/components/SiteTitle.astro", SkipLink: "./src/components/SkipLink.astro", }, logo: { dark: "/src/assets/horizontal-logo-light.svg", light: "/src/assets/horizontal-logo-dark.svg", }, social: [ { href: "/community/invite", icon: "discord", label: "Discord", }, ], sidebar: sidebar, plugins: [ starlightLinksValidator({ exclude: [ // Used in the docs for OpenTelemetry "http://localhost:16686/", "http://localhost:9090/", // Unfortunately, these have to be ignored, as they're referencing content // that is generated outside the contents of the markdown file. "/reference/cli/commands/run#*", "/reference/cli/commands/run/#*", "/reference/cli/commands/list#*", "/reference/cli/commands/list/#*", "/reference/cli/commands/find#*", "/reference/cli/commands/find/#*", // Used as a redirect to the Terragrunt Discord server "/community/invite", ], }), starlightLlmsTxt() ], }), d2({ // It's recommended that we just skip generation in Vercel, // and generate diagrams locally: // https://astro-d2.vercel.app/guides/how-astro-d2-works/#deployment skipGeneration: !!isVercel, }), partytown({ config: { debug: false, logCalls: false, logGetters: false, logSetters: false, logImageRequests: false, logScriptExecution: false, logStackTraces: false, forward: ['dataLayer.push'], }, }), sitemap(), ], // Note that some redirects are handled in vercel.json instead. // // This is because Astro won't do dynamic redirects for external destinations. // It's faster to have Vercel handle it anyways. redirects: { // Catch-all redirect from /docs/* to /* "/docs/[...slug]": "/[...slug]", // Root redirects "/": "/getting-started/quick-start/", "/docs/": "/getting-started/quick-start/", // Pages that have been rehomed. "/features/scaffold/": "/features/catalog/scaffold/", "/features/run-queue/": "/features/stacks/run-queue/", "/features/debugging/": "/troubleshooting/debugging/", "/upgrade/upgrading_to_terragrunt_0.19.x/": "/migrate/upgrading_to_terragrunt_0.19.x/", // Merged pages "/features/stacks/dependencies/": "/features/stacks/stack-operations/", "/features/stacks/orchestration/": "/features/stacks/stack-operations/", // Redirects to external sites. "/terragrunt-ambassador": "https://terragrunt.com/terragrunt-ambassador", "/terragrunt-scale": "https://terragrunt.com/terragrunt-scale", "/contact/": "https://gruntwork.io/contact", "/commercial-support/": "https://gruntwork.io/support", "/cookie-policy/": "https://gruntwork.io/legal/cookie-policy/", // Restructured docs "/reference/configuration/": "/reference/hcl/", "/reference/cli-options/": "/reference/cli/", "/reference/built-in-functions/": "/reference/hcl/functions/", "/reference/config-blocks-and-attributes/": "/reference/hcl/blocks/", "/reference/strict-mode/": "/reference/strict-controls/", "/reference/log-formatting/": "/reference/logging/formatting/", "/features/aws-authentication/": "/features/units/authentication/", "/reference/experiment-mode/": "/reference/experiments/", // Support old doc structure paths "/getting-started/": "/getting-started/quick-start/", "/features/": "/features/units/", "/reference/": "/reference/hcl/", "/troubleshooting/": "/troubleshooting/debugging/", "/migrate/": "/migrate/migrating-from-root-terragrunt-hcl/", // Support old community paths "/community/": "/community/contributing/", "/support/": "/community/support/", // Support old feature paths "/features/inputs/": "/features/units/", "/features/locals/": "/features/units/", "/features/keep-your-terraform-code-dry/": "/features/units/", "/features/execute-terraform-commands-on-multiple-units-at-once/": "/features/stacks/", "/features/keep-your-terragrunt-architecture-dry/": "/features/units/includes/", "/features/keep-your-remote-state-configuration-dry/": "/features/units/state-backend/", "/features/keep-your-cli-flags-dry/": "/features/units/extra-arguments/", "/features/aws-auth/": "/features/units/authentication/", "/features/work-with-multiple-aws-accounts/": "/features/units/authentication/", "/features/auto-retry/": "/features/units/runtime-control/", "/features/provider-cache/": "/features/caching/provider-cache-server/", "/features/provider-caching/": "/features/caching/provider-cache-server/", "/features/engine/": "/features/units/engine/", "/features/run-report/": "/features/stacks/run-report/", "/features/provider-cache-server/": "/features/caching/provider-cache-server/", "/features/auto-provider-cache-dir/": "/features/caching/auto-provider-cache-dir/", "/features/cas/": "/features/caching/cas/", // Additional redirects for 404ing URLs "/features/execute-terraform-commands-on-multiple-modules-at-once/": "/features/stacks/", "/getting-started/configuration/": "/reference/hcl/", "/features/before-and-after-hooks/": "/features/units/hooks/", "/etting-started/configuration/": "/reference/hcl/", // typo in original URL "/features/log-formatting": "/reference/logging/formatting/", "/reference/lock-file-handling/": "/reference/lock-files/", // Restructured docs "/reference/cli/rules": "/process/cli-rules/", // Unit features rehomed under /features/units/ "/features/includes/": "/features/units/includes/", "/features/state-backend/": "/features/units/state-backend/", "/features/extra-arguments/": "/features/units/extra-arguments/", "/features/authentication/": "/features/units/authentication/", "/features/hooks/": "/features/units/hooks/", "/features/auto-init/": "/features/units/auto-init/", "/features/runtime-control/": "/features/units/runtime-control/", // Redirects for external resources "/community/invite": "https://discord.com/invite/YENaT9h8jh", }, vite: { plugins: [ tailwindcss(), { name: 'compatibility-query-redirect', configureServer(server) { server.middlewares.use((req, _res, next) => { const url = req.url ?? ''; if (url === '/api/v1/compatibility' || url.startsWith('/api/v1/compatibility?')) { const qs = url.includes('?') ? url.split('?')[1] : ''; const tool = new URLSearchParams(qs).get('tool'); if (tool === 'opentofu' || tool === 'terraform') { req.url = `/api/v1/compatibility/${tool}`; } else { req.url = '/api/v1/compatibility/index'; } } next(); }); }, }, ], }, }); ================================================ FILE: docs/components.json ================================================ { "_comment": "This file configures the shadcn/ui CLI for adding new components. Use 'npx shadcn@latest add [component-name]' to add new shadcn/ui components. Components will be installed to src/components/ui/ and will use our existing Starlight color system.", "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, "tsx": false, "tailwind": { "config": "tailwind.config.mjs", "css": "src/styles/global.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "aliases": { "components": "src/components", "utils": "src/lib/utils" } } ================================================ FILE: docs/mise.toml ================================================ [tools] bun = "1.2.2" ================================================ FILE: docs/package.json ================================================ { "name": "docs", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro dev", "start": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "@astrojs/compiler-rs": "^0.1.4", "@astrojs/markdown-remark": "7.0.0", "@astrojs/partytown": "^2.1.5", "@astrojs/react": "5.0.0", "@astrojs/sitemap": "3.7.1", "@astrojs/starlight": "0.38.1", "@astrojs/starlight-tailwind": "5.0.0", "@astrojs/vercel": "10.0.0", "@tailwindcss/vite": "^4.2.1", "astro": "6.0.4", "astro-d2": "^0.10.0", "clsx": "^2.1.1", "gray-matter": "^4.0.3", "lucide-react": "^0.577.0", "sharp": "^0.34.5", "starlight-links-validator": "^0.20.1", "starlight-llms-txt": "^0.8.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { "@types/bun": "^1.3.10" }, "overrides": { "estree-walker": "2.0.2" } } ================================================ FILE: docs/public/install ================================================ #!/usr/bin/env bash # Terragrunt Installer # # Supported platforms: Linux, macOS (Darwin) # Supported architectures: x86_64 (amd64), aarch64/arm64, i386/i686 (386) # Requirements: bash 3.2+, curl, sha256sum or shasum # # Note: This script requires bash (not sh) for: # - pipefail option (set -o pipefail) # - local variables in functions # - [[ ]] test syntax # - arrays and readonly declarations # # Usage: # curl -sL https://docs.terragrunt.com/install | bash # curl -sL https://docs.terragrunt.com/install | bash -s -- -v v0.72.5 # curl -sL https://docs.terragrunt.com/install | bash -s -- -d ~/bin # # Options: # -v, --version VERSION Install specific version (default: latest) # -d, --dir PATH Installation directory (default: ~/.terragrunt/bin) # -f, --force Overwrite existing installation # --verify-cosign Use Cosign instead of GPG for signature verification # --no-verify-sig Skip GPG/Cosign signature verification # --no-verify Skip SHA256 checksum verification # -h, --help Show this help message # # Signature verification (GPG) is enabled by default for versions >= v0.98.0. # Use --no-verify-sig to skip, or --verify-cosign to use Cosign instead of GPG. # # Environment: # TERRAGRUNT_VERSION Override version (same as -v) # TERRAGRUNT_INSTALL_DIR Override install directory (same as -d) set -euo pipefail # --- Constants --- readonly GITHUB_REPO="gruntwork-io/terragrunt" readonly GPG_KEY_URL="https://gruntwork.io/.well-known/pgp-key.txt" readonly DEFAULT_INSTALL_DIR="${HOME}/.terragrunt/bin" readonly BINARY_NAME="terragrunt" # Minimum version that has signed release assets (GPG and Cosign) readonly MIN_SIGNED_VERSION="0.98.0" # --- Colors (if terminal) --- # Use $'...' syntax for reliable escape sequence interpretation on macOS/Linux if [[ -t 1 ]]; then readonly RED=$'\033[0;31m' readonly GREEN=$'\033[0;32m' readonly YELLOW=$'\033[0;33m' readonly BLUE=$'\033[0;34m' readonly NC=$'\033[0m' # No Color else readonly RED='' readonly GREEN='' readonly YELLOW='' readonly BLUE='' readonly NC='' fi # --- Helper Functions --- abort() { printf "${RED}Error: %s${NC}\n" "$1" >&2 exit 1 } info() { printf "${BLUE}==> ${NC}%s\n" "$1" } warn() { printf "${YELLOW}Warning: %s${NC}\n" "$1" >&2 } success() { printf "${GREEN}==> %s${NC}\n" "$1" } usage() { cat <= v0.98.0. Use --no-verify-sig to skip, or --verify-cosign to use Cosign instead of GPG. Examples: # Install latest version curl -sL https://docs.terragrunt.com/install | bash # Install specific version curl -sL https://docs.terragrunt.com/install | bash -s -- -v v0.98.0 # Install to custom directory curl -sL https://docs.terragrunt.com/install | bash -s -- -d ~/bin # Install without signature verification curl -sL https://docs.terragrunt.com/install | bash -s -- --no-verify-sig # Install using Cosign instead of GPG curl -sL https://docs.terragrunt.com/install | bash -s -- --verify-cosign EOF } # --- Version Comparison --- # Compare two semantic versions. Returns 0 if $1 >= $2, 1 otherwise. version_gte() { local version=$1 local min_version=$2 # Strip 'v' prefix if present version="${version#v}" min_version="${min_version#v}" # Use sort -V if available, otherwise fall back to manual comparison if echo | sort -V >/dev/null 2>&1; then local sorted_first sorted_first=$(printf '%s\n%s' "$min_version" "$version" | sort -V | head -n1) [[ "$sorted_first" == "$min_version" ]] else # Manual version comparison for systems without sort -V (e.g., older macOS) local i local IFS='.' read -ra v1 <<<"$version" read -ra v2 <<<"$min_version" for ((i = 0; i < ${#v2[@]}; i++)); do local n1=${v1[i]:-0} local n2=${v2[i]:-0} if ((n1 > n2)); then return 0 elif ((n1 < n2)); then return 1 fi done return 0 fi } # Check if version supports signature verification supports_signature_verification() { local version=$1 version_gte "$version" "$MIN_SIGNED_VERSION" } # --- OS/Arch Detection --- detect_os() { local os os="$(uname -s)" case "$os" in Darwin) echo "darwin" ;; Linux) echo "linux" ;; MINGW*|MSYS*|CYGWIN*) abort "Windows detected. Please use PowerShell or install via Chocolatey: choco install terragrunt Or download manually from: https://github.com/gruntwork-io/terragrunt/releases" ;; *) abort "Unsupported operating system: $os Supported: Linux, macOS (Darwin)" ;; esac } detect_arch() { local arch arch="$(uname -m)" case "$arch" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; i386|i686) echo "386" ;; *) abort "Unsupported architecture: $arch Supported: x86_64 (amd64), aarch64 (arm64), i386/i686 (386)" ;; esac } # --- Version Resolution --- get_latest_version() { local version # Method 1: Use redirect URL (higher rate limits than API) # GitHub redirects /releases/latest to /releases/tag/vX.Y.Z local redirect_url if redirect_url=$(curl -fsI "https://github.com/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep -i '^location:' | tr -d '\r'); then version=$(echo "$redirect_url" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) if [[ -n "$version" ]]; then echo "$version" return 0 fi fi # Method 2: Fallback to GitHub API (may hit rate limits: 60 req/hour unauthenticated) if version=$(curl -fsL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4); then if [[ -n "$version" ]]; then echo "$version" return 0 fi fi abort "Could not determine latest version. This may be due to GitHub API rate limits (60 requests/hour). Specify a version manually with -v, e.g.: -v v0.72.5" } validate_version() { local version="$1" # Allow any version/tag (semver, release candidates, custom builds) [[ -z "$version" ]] && abort "Version cannot be empty" echo "$version" } # --- Download Functions --- download_file() { local url="$1" local output="$2" local description="$3" info "Downloading $description..." if ! curl -sL --fail "$url" -o "$output" 2>/dev/null; then abort "Failed to download $description from: $url" fi } download_binary() { local version="$1" local binary_name="$2" local output_dir="$3" local url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${binary_name}" download_file "$url" "${output_dir}/${binary_name}" "Terragrunt ${version}" } download_checksums() { local version="$1" local output_dir="$2" local url="https://github.com/${GITHUB_REPO}/releases/download/${version}/SHA256SUMS" download_file "$url" "${output_dir}/SHA256SUMS" "checksums" } # --- Verification Functions --- verify_sha256() { local binary_path="$1" local checksums_path="$2" local binary_name="$3" info "Verifying SHA256 checksum..." local actual_checksum if command -v sha256sum &>/dev/null; then actual_checksum=$(sha256sum "$binary_path" | awk '{print $1}') elif command -v shasum &>/dev/null; then actual_checksum=$(shasum -a 256 "$binary_path" | awk '{print $1}') else abort "Neither sha256sum nor shasum found. Cannot verify checksum." fi local expected_checksum # Strip CRLF and find checksum for binary expected_checksum=$(tr -d '\r' < "$checksums_path" | awk -v bin="$binary_name" '$2 == bin {print $1; exit}') if [[ -z "$expected_checksum" ]]; then abort "Could not find checksum for $binary_name in SHA256SUMS file" fi if [[ "$actual_checksum" != "$expected_checksum" ]]; then abort "Checksum verification failed! Expected: $expected_checksum Got: $actual_checksum The downloaded file may be corrupted or tampered with." fi } verify_gpg() { local version="$1" local checksums_path="$2" local tmpdir="$3" local sig_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/SHA256SUMS.gpgsig" local sig_path="${tmpdir}/SHA256SUMS.gpgsig" local gnupg_home="${tmpdir}/gnupg" info "Downloading GPG signature..." if ! curl -sL --fail "$sig_url" -o "$sig_path" 2>/dev/null; then warn "Failed to download GPG signature file" return 1 fi # Create temporary GNUPGHOME to avoid polluting user's keyring mkdir -p "$gnupg_home" chmod 700 "$gnupg_home" info "Importing Gruntwork GPG key..." if ! curl -sL "$GPG_KEY_URL" | GNUPGHOME="$gnupg_home" gpg --import 2>/dev/null; then warn "Failed to import GPG key" return 1 fi info "Verifying GPG signature..." if GNUPGHOME="$gnupg_home" gpg --verify "$sig_path" "$checksums_path" 2>/dev/null; then return 0 else return 1 fi } verify_cosign() { local version="$1" local checksums_path="$2" local tmpdir="$3" # Try bundle verification first (cosign v3+ / sigstore bundle) local bundle_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/SHA256SUMS.sigstore.json" local bundle_path="${tmpdir}/SHA256SUMS.sigstore.json" if curl -sL --fail "$bundle_url" -o "$bundle_path" 2>/dev/null; then info "Verifying Cosign signature (bundle)..." cosign verify-blob "$checksums_path" \ --bundle "$bundle_path" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-identity-regexp "github.com/gruntwork-io/terragrunt" 2>/dev/null return $? fi # Legacy .sig/.pem verification (older releases without bundle) local sig_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/SHA256SUMS.sig" local cert_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/SHA256SUMS.pem" local sig_path="${tmpdir}/SHA256SUMS.sig" local cert_path="${tmpdir}/SHA256SUMS.pem" info "Downloading Cosign signature files..." curl -sL --fail "$sig_url" -o "$sig_path" 2>/dev/null || { warn "Failed to download Cosign signature file"; return 1; } curl -sL --fail "$cert_url" -o "$cert_path" 2>/dev/null || { warn "Failed to download Cosign certificate file"; return 1; } info "Verifying Cosign signature..." cosign verify-blob "$checksums_path" \ --signature "$sig_path" \ --certificate "$cert_path" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-identity-regexp "github.com/gruntwork-io/terragrunt" 2>/dev/null return $? } # Verify signature using specified method verify_signature() { local version="$1" local checksums_path="$2" local tmpdir="$3" local method="$4" # gpg or cosign case "$method" in gpg) command -v gpg &>/dev/null || abort "GPG verification requested but gpg is not installed" verify_gpg "$version" "$checksums_path" "$tmpdir" && return 0 abort "GPG signature verification failed!" ;; cosign) command -v cosign &>/dev/null || abort "Cosign verification requested but cosign is not installed" verify_cosign "$version" "$checksums_path" "$tmpdir" && return 0 abort "Cosign signature verification failed!" ;; esac } # --- Shell RC Detection --- detect_shell_rc() { local shell_name shell_name=$(basename "${SHELL:-}") case "$shell_name" in bash) if [[ -f "${HOME}/.bashrc" ]]; then echo "${HOME}/.bashrc" elif [[ -f "${HOME}/.bash_profile" ]]; then echo "${HOME}/.bash_profile" fi ;; zsh) echo "${HOME}/.zshrc" ;; fish) echo "${HOME}/.config/fish/config.fish" ;; esac } # Check if PATH already contains install dir path_already_configured() { local install_dir="$1" local rc_file rc_file=$(detect_shell_rc) [[ -n "$rc_file" ]] && grep -Fq "${install_dir}" "$rc_file" 2>/dev/null } # --- Installation --- install_binary() { local binary_path="$1" local install_dir="$2" local force="$3" local requested_version="$4" local target_path="${install_dir}/${BINARY_NAME}" # Check if already exists (skip if force) if [[ -f "$target_path" && "$force" != "true" ]]; then local existing_version existing_version=$("$target_path" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 || echo "unknown") [[ "$existing_version" == "$requested_version" ]] && \ abort "Terragrunt ${existing_version} is already installed at $target_path" abort "A different version (${existing_version}) is installed at $target_path Use --force to upgrade/downgrade to ${requested_version}" fi # Create install directory if needed [[ ! -d "$install_dir" ]] && { info "Creating installation directory: $install_dir" mkdir -p "$install_dir" 2>/dev/null || abort "Failed to create installation directory: $install_dir" } # Check write permissions [[ ! -w "$install_dir" ]] && abort "Cannot write to $install_dir Run with sudo: curl -sL https://docs.terragrunt.com/install | sudo bash Or specify a different directory: curl -sL https://docs.terragrunt.com/install | bash -s -- -d ~/bin" info "Installing to ${target_path}..." install -m 0755 "$binary_path" "$target_path" } # --- Argument Parsing --- parse_args() { # Set defaults from environment or hardcoded values VERSION="${TERRAGRUNT_VERSION:-}" INSTALL_DIR="${TERRAGRUNT_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" VERIFY_SHA=true VERIFY_SIG="gpg" # gpg (default), cosign, or empty (via --no-verify-sig) SKIP_SIG_VERIFY=false # set by --no-verify-sig to disable signature verification FORCE=false while [[ $# -gt 0 ]]; do case "$1" in -v|--version) [[ -z "${2:-}" ]] && abort "Option $1 requires a version argument" VERSION="$2" shift 2 ;; -d|--dir) [[ -z "${2:-}" ]] && abort "Option $1 requires a directory argument" INSTALL_DIR="$2" shift 2 ;; -f|--force) FORCE=true shift ;; --verify-cosign) VERIFY_SIG="cosign" shift ;; --no-verify-sig) SKIP_SIG_VERIFY=true shift ;; --no-verify) VERIFY_SHA=false shift ;; -h|--help) usage exit 0 ;; -*) abort "Unknown option: $1 Use -h or --help for usage information" ;; *) abort "Unexpected argument: $1 Use -h or --help for usage information" ;; esac done } # --- Dependency Check --- check_dependencies() { command -v curl &>/dev/null || abort "curl is required but not installed. Please install curl and try again." [[ "$VERIFY_SHA" == true ]] && ! command -v sha256sum &>/dev/null && ! command -v shasum &>/dev/null && \ abort "Neither sha256sum nor shasum found. Install one of these tools or skip checksum verification with: curl -sL https://docs.terragrunt.com/install | bash -s -- --no-verify" # Handle signature verification setup [[ "$SKIP_SIG_VERIFY" == true ]] && { VERIFY_SIG=""; return; } # Verify required tool is available case "$VERIFY_SIG" in gpg) command -v gpg &>/dev/null || abort "GPG signature verification requires gpg but it is not installed. Install gpg or skip signature verification with: curl -sL https://docs.terragrunt.com/install | bash -s -- --no-verify-sig Or use Cosign instead: curl -sL https://docs.terragrunt.com/install | bash -s -- --verify-cosign" ;; cosign) command -v cosign &>/dev/null || abort "Cosign verification requested but cosign is not installed." ;; esac } # --- Main --- main() { parse_args "$@" # Expand tilde in INSTALL_DIR (bash doesn't expand ~ in quoted variables) INSTALL_DIR="${INSTALL_DIR/#\~/$HOME}" # Check dependencies check_dependencies # Detect platform local os arch version binary_name os=$(detect_os) arch=$(detect_arch) # Resolve version if [[ -z "$VERSION" ]]; then info "Fetching latest version..." version=$(get_latest_version) else version=$(validate_version "$VERSION") fi binary_name="terragrunt_${os}_${arch}" info "Installing Terragrunt ${version} for ${os}/${arch}" # Create temp directory with safe cleanup local tmpdir tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'terragrunt-install') trap '[[ -n "${tmpdir:-}" && -d "${tmpdir:-}" ]] && rm -rf "$tmpdir"' EXIT # Download files download_binary "$version" "$binary_name" "$tmpdir" download_checksums "$version" "$tmpdir" # Verify signature first (authenticates SHA256SUMS file) if [[ -n "$VERIFY_SIG" ]]; then if supports_signature_verification "$version"; then verify_signature "$version" "$tmpdir/SHA256SUMS" "$tmpdir" "$VERIFY_SIG" success "Signature verified" else warn "Skipping signature verification: not available for versions older than v${MIN_SIGNED_VERSION}" fi fi # Verify checksum (validates binary against authenticated checksums) if [[ "$VERIFY_SHA" == true ]]; then verify_sha256 "$tmpdir/$binary_name" "$tmpdir/SHA256SUMS" "$binary_name" success "SHA256 checksum verified" else warn "Skipping checksum verification (--no-verify specified)" fi # Install install_binary "$tmpdir/$binary_name" "$INSTALL_DIR" "$FORCE" "$version" local target_path="${INSTALL_DIR}/${BINARY_NAME}" success "Terragrunt ${version} installed successfully to ${target_path}" echo "" # Show PATH instructions if using default dir and not already configured if [[ "$INSTALL_DIR" == "$DEFAULT_INSTALL_DIR" ]] && ! path_already_configured "$INSTALL_DIR"; then local rc_file rc_file=$(detect_shell_rc) if [[ -n "$rc_file" ]]; then echo "To add terragrunt to your PATH, run:" echo "" echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ${rc_file}" echo " source ${rc_file}" else echo "Add to your shell configuration:" echo "" echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" fi echo "" fi echo "Run 'terragrunt --help' to get started." echo "For documentation, visit: https://docs.terragrunt.com/" } main "$@" ================================================ FILE: docs/public/robots.txt ================================================ User-agent: * Allow: / Sitemap: https://docs.terragrunt.com/sitemap-index.xml ================================================ FILE: docs/public/schemas/auth-provider-cmd/v1/schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://terragrunt.gruntwork.io/schemas/auth-provider-cmd/v1/schema.json", "title": "Terragrunt Auth Provider Command Response Schema", "description": "Schema for the JSON response expected from an auth provider command", "type": "object", "properties": { "awsCredentials": { "type": "object", "description": "AWS credentials to set as environment variables", "properties": { "ACCESS_KEY_ID": { "type": "string", "description": "AWS access key ID" }, "SECRET_ACCESS_KEY": { "type": "string", "description": "AWS secret access key" }, "SESSION_TOKEN": { "type": "string", "description": "AWS session token (optional)" } }, "required": [ "ACCESS_KEY_ID", "SECRET_ACCESS_KEY" ], "additionalProperties": false }, "awsRole": { "type": "object", "description": "AWS role to assume", "properties": { "roleARN": { "type": "string", "description": "The ARN of the IAM role to assume" }, "roleSessionName": { "type": "string", "description": "The session name for the assumed role" }, "duration": { "type": "integer", "description": "Duration in seconds for the assumed role session", "minimum": 0 }, "webIdentityToken": { "type": "string", "description": "Web identity token for OIDC-based role assumption" } }, "required": [ "roleARN" ], "additionalProperties": false }, "envs": { "type": "object", "description": "Additional environment variables to set", "additionalProperties": { "type": "string" } } }, "additionalProperties": false } ================================================ FILE: docs/public/schemas/auth-provider-cmd/v2/schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://docs.terragrunt.com/schemas/auth-provider-cmd/v2/schema.json", "title": "Terragrunt Auth Provider Command Response Schema", "description": "Schema for the JSON response expected from an auth provider command", "type": "object", "properties": { "awsCredentials": { "type": "object", "description": "AWS credentials to set as environment variables", "properties": { "ACCESS_KEY_ID": { "type": "string", "description": "AWS access key ID" }, "SECRET_ACCESS_KEY": { "type": "string", "description": "AWS secret access key" }, "SESSION_TOKEN": { "type": "string", "description": "AWS session token (optional)" } }, "required": [ "ACCESS_KEY_ID", "SECRET_ACCESS_KEY" ], "additionalProperties": false }, "awsRole": { "type": "object", "description": "AWS role to assume", "properties": { "roleARN": { "type": "string", "description": "The ARN of the IAM role to assume" }, "roleSessionName": { "type": "string", "description": "The session name for the assumed role" }, "duration": { "type": "integer", "description": "Duration in seconds for the assumed role session", "minimum": 0 }, "webIdentityToken": { "type": "string", "description": "Web identity token for OIDC-based role assumption" } }, "required": [ "roleARN" ], "additionalProperties": false }, "envs": { "type": "object", "description": "Additional environment variables to set", "additionalProperties": { "type": "string" } } }, "additionalProperties": false } ================================================ FILE: docs/public/schemas/run/report/v1/schema.json ================================================ { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://terragrunt.gruntwork.io/schemas/run/report/v1/schema.json", "properties": { "Started": { "type": "string", "format": "date-time" }, "Ended": { "type": "string", "format": "date-time" }, "Reason": { "type": "string", "enum": [ "retry succeeded", "error ignored", "run error", "--queue-exclude-dir", "exclude block", "ancestor error" ] }, "Cause": { "type": "string" }, "Name": { "type": "string" }, "Result": { "type": "string", "enum": [ "succeeded", "failed", "early exit", "excluded" ] } }, "additionalProperties": false, "type": "object", "required": [ "Started", "Ended", "Name", "Result" ], "title": "Terragrunt Run Report Schema", "description": "Schema for Terragrunt run report" }, "type": "array", "title": "Terragrunt Run Report Schema", "description": "Array of Terragrunt runs" } ================================================ FILE: docs/public/schemas/run/report/v2/schema.json ================================================ { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://terragrunt.gruntwork.io/schemas/run/report/v2/schema.json", "properties": { "Started": { "type": "string", "format": "date-time" }, "Ended": { "type": "string", "format": "date-time" }, "Reason": { "type": "string", "enum": [ "retry succeeded", "error ignored", "run error", "exclude block", "ancestor error" ] }, "Cause": { "type": "string" }, "Name": { "type": "string" }, "Result": { "type": "string", "enum": [ "succeeded", "failed", "early exit", "excluded" ] } }, "additionalProperties": false, "type": "object", "required": [ "Started", "Ended", "Name", "Result" ], "title": "Terragrunt Run Report Schema", "description": "Schema for Terragrunt run report" }, "type": "array", "title": "Terragrunt Run Report Schema", "description": "Array of Terragrunt runs" } ================================================ FILE: docs/public/schemas/run/report/v3/schema.json ================================================ { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://terragrunt.gruntwork.io/schemas/run/report/v3/schema.json", "properties": { "Started": { "type": "string", "format": "date-time" }, "Ended": { "type": "string", "format": "date-time" }, "Reason": { "type": "string", "enum": [ "retry succeeded", "error ignored", "run error", "exclude block", "ancestor error" ] }, "Cause": { "type": "string" }, "Name": { "type": "string" }, "Result": { "type": "string", "enum": [ "succeeded", "failed", "early exit", "excluded" ] }, "Ref": { "type": "string" }, "Cmd": { "type": "string" }, "Args": { "items": { "type": "string" }, "type": "array" } }, "additionalProperties": false, "type": "object", "required": [ "Started", "Ended", "Name", "Result" ], "title": "Terragrunt Run Report Schema", "description": "Schema for Terragrunt run report" }, "type": "array", "title": "Terragrunt Run Report Schema", "description": "Array of Terragrunt runs" } ================================================ FILE: docs/public/schemas/run/report/v4/schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://docs.terragrunt.com/schemas/run/report/v4/schema.json", "items": { "properties": { "Started": { "type": "string", "format": "date-time" }, "Ended": { "type": "string", "format": "date-time" }, "Reason": { "type": "string", "enum": [ "retry succeeded", "error ignored", "run error", "exclude block", "ancestor error" ] }, "Cause": { "type": "string" }, "Name": { "type": "string" }, "Result": { "type": "string", "enum": [ "succeeded", "failed", "early exit", "excluded" ] }, "Ref": { "type": "string" }, "Cmd": { "type": "string" }, "Args": { "items": { "type": "string" }, "type": "array" } }, "additionalProperties": false, "type": "object", "required": [ "Started", "Ended", "Name", "Result" ], "title": "Terragrunt Run Report Schema", "description": "Schema for Terragrunt run report" }, "type": "array", "title": "Terragrunt Run Report Schema", "description": "Array of Terragrunt runs" } ================================================ FILE: docs/src/assets/icons/terragrunt-icon-accent.astro ================================================ --- --- ================================================ FILE: docs/src/components/Command.astro ================================================ --- import { Aside, Code } from '@astrojs/starlight/components'; import { getEntry, render } from 'astro:content'; import type { CollectionEntry } from 'astro:content'; import Flag from './Flag.astro'; const { path } = Astro.props; const command = await getEntry('commands', path) as CollectionEntry<'commands'>; const data = command?.data; const usage = data?.usage; const { Content } = await render(command); --- { data?.experiment ? ( ) : null }

Usage

{ usage?.split('\n').map((line) => (

{line}

)) } { data?.examples ? (

Examples

{data?.examples.map((example) => ( example && (

{example?.description}

) ))}
) : null } { data?.flags ? (

Flags

{data?.flags.map((flagSlug) => ( flagSlug && ))}
) : null } ================================================ FILE: docs/src/components/CompactFooter.astro ================================================ --- import GruntworkLogo from '@assets/gruntwork-logo.svg'; import { Image } from 'astro:assets'; import PatternDots from '@assets/pattern-dots.png'; import '@styles/global.css'; ---
Pattern Dots
Gruntwork Logo From the DevOps experts at Gruntwork

© {new Date().getFullYear()} Gruntwork, Inc. All rights reserved.

================================================ FILE: docs/src/components/CompatibilityTable.astro ================================================ --- import { getCollection } from 'astro:content'; interface Props { tool: 'opentofu' | 'terraform'; } const { tool } = Astro.props; const entries = (await getCollection('compatibility')) .filter(e => e.data.tool === tool) .sort((a, b) => b.data.order - a.data.order); const label = tool === 'opentofu' ? 'OpenTofu' : 'Terraform'; const baseUrl = 'https://github.com/gruntwork-io/terragrunt/releases/tag/v'; --- {entries.map(e => ( ))}
{label} Version Terragrunt Version
{e.data.version} {e.data.terragrunt_max ? ( <>{e.data.terragrunt_min} - {e.data.terragrunt_max} ) : ( <>>= {e.data.terragrunt_min} )}
================================================ FILE: docs/src/components/Flag.astro ================================================ --- import { Badge } from '@astrojs/starlight/components'; import Card from '@components/vendored/starlight/Card.astro'; import type { CollectionEntry } from 'astro:content'; import { render, getEntry } from 'astro:content'; interface Props { slug: string; } const { slug } = Astro.props; const flagEntry = await getEntry('flags', slug) as CollectionEntry<'flags'>; const { Content } = await render(flagEntry); const { aliases, description, type, env, name, defaultVal } = flagEntry.data; ---

{description}

Type:
{defaultVal && (
Default:
)} {aliases && aliases.length > 0 && ( <>

Aliases:

    {aliases.map((alias: string) => (
  • {alias}
  • ))}
)} {env && ( <>

Environment Variables:

    {env.map((envVar: string) => (
  • {envVar}
  • ))}
)}
================================================ FILE: docs/src/components/Header.astro ================================================ --- import config from 'virtual:starlight/user-config'; import { Image } from 'astro:assets'; // @ts-ignore import Search from 'virtual:starlight/components/Search'; import HeadphonesIcon from '@assets/headset-icon.svg'; import PipelineIcon from '@assets/pipelines.svg'; import TerragruntLogo from '@assets/horizontal-logo-light.svg'; import ThemeToggle from '@components/ThemeToggle.astro'; import ButtonLink from '@components/ui/ButtonLink'; import { getGitHubRepo, formatStarCount } from '@lib/github'; import '@styles/global.css'; /** * Render the `Search` component if Pagefind is enabled or the default search component has been overridden. */ const shouldRenderSearch = config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro'; // This will switch between dark and light mode. // Some pages do not have a dark mode, so this allows us to show or hide the switch. interface Props { showThemeToggle?: boolean; } const { showThemeToggle = true } = Astro.props; // Github Stars let starCountDisplay = process.env.GITHUB_STARS || '9.2k'; if (!process.env.GITHUB_STARS) { const repoData = await getGitHubRepo('gruntwork-io', 'terragrunt'); if (repoData && typeof repoData.stargazers_count === 'number') { starCountDisplay = formatStarCount(repoData.stargazers_count); } } --- ================================================ FILE: docs/src/components/InstallTab.astro ================================================ --- import { TabItem } from '@astrojs/starlight/components'; import { Code } from '@astrojs/starlight/components'; const { os, arch, label, version } = Astro.props; --- {os === 'windows' ? ( $null Write-Host "Verifying GPG signature of SHA256SUMS..." gpg --verify SHA256SUMS.gpgsig SHA256SUMS if ($LASTEXITCODE -ne 0) { Write-Error "GPG signature verification failed" exit 1 } Write-Host "GPG signature verified!" # Second: Verify checksum of binary against trusted SHA256SUMS $actualChecksum = (Get-FileHash -Algorithm SHA256 $binaryName).Hash.ToLower() $expectedChecksum = (Get-Content "SHA256SUMS" | ForEach-Object { $parts = $_ -split '\s+'; if ($parts[1] -eq $binaryName) { return $parts[0].ToLower() } } | Select-Object -First 1) if ($actualChecksum -ne $expectedChecksum) { Write-Error "Checksum verification failed" exit 1 } Write-Host "Checksum verified!" Write-Host "Terragrunt $version downloaded and verified successfully" } catch { Write-Error "Failed: $_" exit 1 } finally { $ProgressPreference = 'Continue' } `} frame='terminal' > ) : ( /dev/null if gpg --verify SHA256SUMS.gpgsig SHA256SUMS 2>/dev/null; then echo "GPG signature verified!" else echo "GPG signature verification failed!" exit 1 fi # Second: Verify checksum of binary against trusted SHA256SUMS CHECKSUM="\$(${os == 'linux' ? 'sha256sum' : 'shasum -a 256'} "\$BINARY_NAME" | awk '{print \$1}')" EXPECTED_CHECKSUM="\$(awk -v binary="\$BINARY_NAME" '\$2 == binary {print \$1; exit}' SHA256SUMS)" if [ "\$CHECKSUM" != "\$EXPECTED_CHECKSUM" ]; then echo "Checksum verification failed!" exit 1 fi echo "Checksum verified!" echo "Terragrunt \$VERSION downloaded and verified successfully"`} frame='terminal' > )} ================================================ FILE: docs/src/components/InstallTabs.astro ================================================ --- import { Tabs } from '@astrojs/starlight/components'; import InstallTab from './InstallTab.astro'; const tabs = [ { os: 'linux', arch: 'amd64', label: 'Linux (x86)', }, { os: 'darwin', arch: 'arm64', label: 'macOS (ARM)', }, { os: 'windows', arch: 'amd64', label: 'Windows', }, { os: 'linux', arch: 'arm64', label: 'Linux (ARM)', }, { os: 'darwin', arch: 'x86', label: 'macOS (x86)', }, ]; const { version } = Astro.props; --- {tabs.map(({ os, arch, label }) => ( ))} ================================================ FILE: docs/src/components/PageSidebar.astro ================================================ --- import DefaultPageSidebar from '@astrojs/starlight/components/PageSidebar.astro'; import { Icon } from '@astrojs/starlight/components'; ---
================================================ FILE: docs/src/components/SectionSpacer.astro ================================================ --- ---
================================================ FILE: docs/src/components/SiteTitle.astro ================================================ --- import { logos } from 'virtual:starlight/user-images'; import config from 'virtual:starlight/user-config'; import type { Props } from '@astrojs/starlight/props'; const { siteTitleHref } = Astro.props; --- { config.logo && logos.dark && ( <> {config.logo.alt} {/* Show light alternate if a user configure both light and dark logos. */} {!('src' in config.logo) && ( )} ) } ================================================ FILE: docs/src/components/SkipLink.astro ================================================ --- import DefaultSkipLink from '@astrojs/starlight/components/SkipLink.astro'; --- ================================================ FILE: docs/src/components/ThemeToggle.astro ================================================ --- const { className: customClass = "", id: uniqueId = `themeToggle-${Math.random().toString(36).substr(2, 9)}` } = Astro.props; --- ================================================ FILE: docs/src/components/dv-IconButton.astro ================================================ --- import '@styles/global.css'; import { Image } from "astro:assets"; import type { ImageMetadata } from 'astro'; interface Props { alt: string; bgcolor?: string; eager?: boolean; src: ImageMetadata; text: string; textColor?: string; } const { alt, bgcolor = '', eager = false, src, text, textColor = '', } = Astro.props as Props; const loadingMode = eager ? 'eager' : 'lazy'; ---
{alt} {text}
================================================ FILE: docs/src/components/ui/Button.tsx ================================================ // Generated with 'npx shadcn@latest add button' // Customize this as needed! import { cn } from "../../lib/utils"; import { ExternalLink } from "lucide-react"; export interface ButtonProps { // TODO: Style secondary, ghost, outline, and link bvariants variant?: "primary" | "secondary" | "ghost" | "outline" | "destructive" | "link"; size?: "default" | "sm" | "lg" | "full" | "icon"; className?: string; children: React.ReactNode; id?: string; onClick?: () => void; type?: "button" | "submit" | "reset"; isExternalLink?: boolean; } export function isSizeIcon(size?: string): boolean { return size === "icon"; } export default function Button({ variant = "primary", size = "default", className, children, onClick, type = "button", isExternalLink = false, ...props }: ButtonProps) { return ( ); } ================================================ FILE: docs/src/components/ui/ButtonLink.tsx ================================================ // A plain Button is often used as a link, so rather than wrapping plain button in an tag, we can use this component. import { cn } from "../../lib/utils"; import Button from "./Button"; // Extract the ButtonProps interface from Button component type ButtonProps = React.ComponentProps; interface ButtonLinkProps extends ButtonProps { buttonClassName?: string; href?: string; rel?: string; target?: "_blank" | "_self" | "_parent" | "_top"; } export default function ButtonLink({ buttonClassName, href, rel, target, className, ...buttonProps }: ButtonLinkProps) { return ( {{/each}} {{else}}
No images found. Upload some cat pictures to get started!
{{/if}} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/mise.toml ================================================ [tools] aws = "2.27.63" node = "22.17.1" opentofu = "1.10.3" terragrunt = "0.83.2" ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/.auto.tfvars.example ================================================ # Example configuration file - copy to terraform.tfvars and update values # Required: Name used for all resources (must be unique) name = "best-cat-2025-07-31-01" # Required: Path to your Lambda function zip file lambda_zip_file = "../../../dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/backend.tf ================================================ terraform { backend "s3" { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/data.tf ================================================ data "aws_caller_identity" "current" {} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/ddb.tf ================================================ resource "aws_dynamodb_table" "asset_metadata" { name = "${var.name}-asset-metadata" billing_mode = "PAY_PER_REQUEST" hash_key = "image_id" attribute { name = "image_id" type = "S" } tags = { Name = "${var.name}-asset-metadata" } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/iam.tf ================================================ resource "aws_iam_role" "lambda_role" { name = "${var.name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_policy" "lambda_s3_read" { name = "${var.name}-lambda-s3-read" description = "Policy for Lambda to read from S3 bucket" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "s3:GetObject", "s3:ListBucket" ] Resource = [ aws_s3_bucket.static_assets.arn, "${aws_s3_bucket.static_assets.arn}/*" ] } ] }) } resource "aws_iam_policy" "lambda_dynamodb" { name = "${var.name}-lambda-dynamodb" description = "Policy for Lambda to read/write to DynamoDB table" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan" ] Resource = aws_dynamodb_table.asset_metadata.arn } ] }) } resource "aws_iam_policy" "lambda_basic_execution" { name = "${var.name}-lambda-basic-execution" description = "Policy for Lambda basic execution (CloudWatch logs)" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" } ] }) } resource "aws_iam_role_policy_attachment" "lambda_s3_read" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_s3_read.arn } resource "aws_iam_role_policy_attachment" "lambda_dynamodb" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_dynamodb.arn } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_basic_execution.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/lambda.tf ================================================ resource "aws_lambda_function" "main" { function_name = "${var.name}-function" filename = var.lambda_zip_file source_code_hash = filebase64sha256(var.lambda_zip_file) role = aws_iam_role.lambda_role.arn handler = var.lambda_handler runtime = var.lambda_runtime timeout = var.lambda_timeout memory_size = var.lambda_memory_size architectures = var.lambda_architectures environment { variables = { S3_BUCKET_NAME = aws_s3_bucket.static_assets.bucket DYNAMODB_TABLE_NAME = aws_dynamodb_table.asset_metadata.name } } depends_on = [ aws_iam_role_policy_attachment.lambda_s3_read, aws_iam_role_policy_attachment.lambda_dynamodb, aws_iam_role_policy_attachment.lambda_basic_execution ] } resource "aws_lambda_function_url" "main" { function_name = aws_lambda_function.main.function_name authorization_type = "NONE" } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = aws_lambda_function_url.main.function_url } output "lambda_function_name" { description = "Name of the Lambda function" value = aws_lambda_function.main.function_name } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = aws_s3_bucket.static_assets.bucket } output "s3_bucket_arn" { description = "ARN of the S3 bucket for static assets" value = aws_s3_bucket.static_assets.arn } output "dynamodb_table_name" { description = "Name of the DynamoDB table for asset metadata" value = aws_dynamodb_table.asset_metadata.name } output "dynamodb_table_arn" { description = "ARN of the DynamoDB table for asset metadata" value = aws_dynamodb_table.asset_metadata.arn } output "lambda_role_arn" { description = "ARN of the Lambda execution role" value = aws_iam_role.lambda_role.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/providers.tf ================================================ provider "aws" { region = var.aws_region } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/s3.tf ================================================ resource "aws_s3_bucket" "static_assets" { bucket = "${var.name}-static-assets" force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "lambda_runtime" { description = "Lambda function runtime" type = string default = "nodejs22.x" } variable "lambda_handler" { description = "Lambda function handler" type = string default = "index.handler" } variable "lambda_timeout" { description = "Lambda function timeout in seconds" type = number default = 30 } variable "lambda_memory_size" { description = "Lambda function memory size in MB" type = number default = 128 } variable "lambda_architectures" { description = "Lambda function architectures" type = list(string) default = ["arm64"] } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-1-starting-the-terralith/live/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/ddb/main.tf ================================================ resource "aws_dynamodb_table" "asset_metadata" { name = "${var.name}-asset-metadata" billing_mode = "PAY_PER_REQUEST" hash_key = "image_id" attribute { name = "image_id" type = "S" } tags = { Name = "${var.name}-asset-metadata" } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/ddb/outputs.tf ================================================ output "name" { value = aws_dynamodb_table.asset_metadata.name } output "arn" { value = aws_dynamodb_table.asset_metadata.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/ddb/vars-required.tf ================================================ variable "name" { description = "The name of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/ddb/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/iam/data.tf ================================================ data "aws_caller_identity" "current" {} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/iam/main.tf ================================================ resource "aws_iam_role" "lambda_role" { name = "${var.name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_policy" "lambda_s3_read" { name = "${var.name}-lambda-s3-read" description = "Policy for Lambda to read from S3 bucket" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "s3:GetObject", "s3:ListBucket" ] Resource = [ var.s3_bucket_arn, "${var.s3_bucket_arn}/*" ] } ] }) } resource "aws_iam_policy" "lambda_dynamodb" { name = "${var.name}-lambda-dynamodb" description = "Policy for Lambda to read/write to DynamoDB table" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan" ] Resource = var.dynamodb_table_arn } ] }) } resource "aws_iam_policy" "lambda_basic_execution" { name = "${var.name}-lambda-basic-execution" description = "Policy for Lambda basic execution (CloudWatch logs)" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" } ] }) } resource "aws_iam_role_policy_attachment" "lambda_s3_read" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_s3_read.arn } resource "aws_iam_role_policy_attachment" "lambda_dynamodb" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_dynamodb.arn } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_basic_execution.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/iam/outputs.tf ================================================ output "name" { value = aws_iam_role.lambda_role.name } output "arn" { value = aws_iam_role.lambda_role.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/iam/vars-required.tf ================================================ variable "name" { description = "The name of the IAM role" type = string } variable "aws_region" { description = "The AWS region to deploy the resources to" type = string } variable "s3_bucket_arn" { description = "The ARN of the S3 bucket" type = string } variable "dynamodb_table_arn" { description = "The ARN of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/iam/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/lambda/main.tf ================================================ resource "aws_lambda_function" "main" { function_name = "${var.name}-function" filename = var.lambda_zip_file source_code_hash = filebase64sha256(var.lambda_zip_file) role = var.lambda_role_arn handler = var.lambda_handler runtime = var.lambda_runtime timeout = var.lambda_timeout memory_size = var.lambda_memory_size architectures = var.lambda_architectures environment { variables = { S3_BUCKET_NAME = var.s3_bucket_name DYNAMODB_TABLE_NAME = var.dynamodb_table_name } } } resource "aws_lambda_function_url" "main" { function_name = aws_lambda_function.main.function_name authorization_type = "NONE" } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/lambda/outputs.tf ================================================ output "name" { value = aws_lambda_function.main.function_name } output "arn" { value = aws_lambda_function.main.arn } output "url" { value = aws_lambda_function_url.main.function_url } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/lambda/vars-optional.tf ================================================ variable "lambda_runtime" { description = "Lambda function runtime" type = string default = "nodejs22.x" } variable "lambda_handler" { description = "Lambda function handler" type = string default = "index.handler" } variable "lambda_timeout" { description = "Lambda function timeout in seconds" type = number default = 30 } variable "lambda_memory_size" { description = "Lambda function memory size in MB" type = number default = 128 } variable "lambda_architectures" { description = "Lambda function architectures" type = list(string) default = ["arm64"] } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/lambda/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "aws_region" { description = "AWS region to deploy the resources to" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } variable "lambda_role_arn" { description = "Lambda function role ARN" type = string } variable "s3_bucket_name" { description = "S3 bucket name" type = string } variable "dynamodb_table_name" { description = "DynamoDB table name" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/lambda/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/s3/main.tf ================================================ resource "aws_s3_bucket" "static_assets" { bucket = "${var.name}-static-assets" force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/s3/outputs.tf ================================================ output "name" { value = aws_s3_bucket.static_assets.bucket } output "arn" { value = aws_s3_bucket.static_assets.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/s3/vars-optional.tf ================================================ variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/s3/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/catalog/modules/s3/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/.auto.tfvars.example ================================================ # Example configuration file - copy to terraform.tfvars and update values # Required: Name used for all resources (must be unique) name = "best-cat-2025-07-31-01" # Required: Path to your Lambda function zip file lambda_zip_file = "../../../dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/backend.tf ================================================ terraform { backend "s3" { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/main.tf ================================================ module "s3" { source = "../catalog/modules/s3" name = var.name force_destroy = var.force_destroy } module "ddb" { source = "../catalog/modules/ddb" name = var.name } module "iam" { source = "../catalog/modules/iam" name = var.name aws_region = var.aws_region s3_bucket_arn = module.s3.arn dynamodb_table_arn = module.ddb.arn } module "lambda" { source = "../catalog/modules/lambda" name = var.name aws_region = var.aws_region s3_bucket_name = module.s3.name dynamodb_table_name = module.ddb.name lambda_zip_file = var.lambda_zip_file lambda_role_arn = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/moved.tf ================================================ moved { from = aws_dynamodb_table.asset_metadata to = module.ddb.aws_dynamodb_table.asset_metadata } moved { from = aws_iam_policy.lambda_basic_execution to = module.iam.aws_iam_policy.lambda_basic_execution } moved { from = aws_iam_policy.lambda_dynamodb to = module.iam.aws_iam_policy.lambda_dynamodb } moved { from = aws_iam_policy.lambda_s3_read to = module.iam.aws_iam_policy.lambda_s3_read } moved { from = aws_iam_role.lambda_role to = module.iam.aws_iam_role.lambda_role } moved { from = aws_iam_role_policy_attachment.lambda_basic_execution to = module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = aws_iam_role_policy_attachment.lambda_dynamodb to = module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = aws_iam_role_policy_attachment.lambda_s3_read to = module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = aws_lambda_function.main to = module.lambda.aws_lambda_function.main } moved { from = aws_lambda_function_url.main to = module.lambda.aws_lambda_function_url.main } moved { from = aws_s3_bucket.static_assets to = module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.lambda.url } output "lambda_function_name" { description = "Name of the Lambda function" value = module.lambda.name } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.s3.name } output "s3_bucket_arn" { description = "ARN of the S3 bucket for static assets" value = module.s3.arn } output "dynamodb_table_name" { description = "Name of the DynamoDB table for asset metadata" value = module.ddb.name } output "dynamodb_table_arn" { description = "ARN of the DynamoDB table for asset metadata" value = module.ddb.arn } output "lambda_role_arn" { description = "ARN of the Lambda execution role" value = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/providers.tf ================================================ provider "aws" { region = var.aws_region } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-2-refactoring/live/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/best_cat/main.tf ================================================ module "s3" { source = "../s3" name = var.name force_destroy = var.force_destroy } module "ddb" { source = "../ddb" name = var.name } module "iam" { source = "../iam" name = var.name aws_region = var.aws_region s3_bucket_arn = module.s3.arn dynamodb_table_arn = module.ddb.arn } module "lambda" { source = "../lambda" name = var.name aws_region = var.aws_region s3_bucket_name = module.s3.name dynamodb_table_name = module.ddb.name lambda_zip_file = var.lambda_zip_file lambda_role_arn = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/best_cat/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.lambda.url } output "lambda_function_name" { description = "Name of the Lambda function" value = module.lambda.name } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.s3.name } output "s3_bucket_arn" { description = "ARN of the S3 bucket for static assets" value = module.s3.arn } output "dynamodb_table_name" { description = "Name of the DynamoDB table for asset metadata" value = module.ddb.name } output "dynamodb_table_arn" { description = "ARN of the DynamoDB table for asset metadata" value = module.ddb.arn } output "lambda_role_arn" { description = "ARN of the Lambda execution role" value = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/best_cat/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/best_cat/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/ddb/main.tf ================================================ resource "aws_dynamodb_table" "asset_metadata" { name = "${var.name}-asset-metadata" billing_mode = "PAY_PER_REQUEST" hash_key = "image_id" attribute { name = "image_id" type = "S" } tags = { Name = "${var.name}-asset-metadata" } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/ddb/outputs.tf ================================================ output "name" { value = aws_dynamodb_table.asset_metadata.name } output "arn" { value = aws_dynamodb_table.asset_metadata.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/ddb/vars-required.tf ================================================ variable "name" { description = "The name of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/ddb/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/iam/data.tf ================================================ data "aws_caller_identity" "current" {} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/iam/main.tf ================================================ resource "aws_iam_role" "lambda_role" { name = "${var.name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_policy" "lambda_s3_read" { name = "${var.name}-lambda-s3-read" description = "Policy for Lambda to read from S3 bucket" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "s3:GetObject", "s3:ListBucket" ] Resource = [ var.s3_bucket_arn, "${var.s3_bucket_arn}/*" ] } ] }) } resource "aws_iam_policy" "lambda_dynamodb" { name = "${var.name}-lambda-dynamodb" description = "Policy for Lambda to read/write to DynamoDB table" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan" ] Resource = var.dynamodb_table_arn } ] }) } resource "aws_iam_policy" "lambda_basic_execution" { name = "${var.name}-lambda-basic-execution" description = "Policy for Lambda basic execution (CloudWatch logs)" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" } ] }) } resource "aws_iam_role_policy_attachment" "lambda_s3_read" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_s3_read.arn } resource "aws_iam_role_policy_attachment" "lambda_dynamodb" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_dynamodb.arn } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_basic_execution.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/iam/outputs.tf ================================================ output "name" { value = aws_iam_role.lambda_role.name } output "arn" { value = aws_iam_role.lambda_role.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/iam/vars-required.tf ================================================ variable "name" { description = "The name of the IAM role" type = string } variable "aws_region" { description = "The AWS region to deploy the resources to" type = string } variable "s3_bucket_arn" { description = "The ARN of the S3 bucket" type = string } variable "dynamodb_table_arn" { description = "The ARN of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/iam/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/lambda/main.tf ================================================ resource "aws_lambda_function" "main" { function_name = "${var.name}-function" filename = var.lambda_zip_file source_code_hash = filebase64sha256(var.lambda_zip_file) role = var.lambda_role_arn handler = var.lambda_handler runtime = var.lambda_runtime timeout = var.lambda_timeout memory_size = var.lambda_memory_size architectures = var.lambda_architectures environment { variables = { S3_BUCKET_NAME = var.s3_bucket_name DYNAMODB_TABLE_NAME = var.dynamodb_table_name } } } resource "aws_lambda_function_url" "main" { function_name = aws_lambda_function.main.function_name authorization_type = "NONE" } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/lambda/outputs.tf ================================================ output "name" { value = aws_lambda_function.main.function_name } output "arn" { value = aws_lambda_function.main.arn } output "url" { value = aws_lambda_function_url.main.function_url } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/lambda/vars-optional.tf ================================================ variable "lambda_runtime" { description = "Lambda function runtime" type = string default = "nodejs22.x" } variable "lambda_handler" { description = "Lambda function handler" type = string default = "index.handler" } variable "lambda_timeout" { description = "Lambda function timeout in seconds" type = number default = 30 } variable "lambda_memory_size" { description = "Lambda function memory size in MB" type = number default = 128 } variable "lambda_architectures" { description = "Lambda function architectures" type = list(string) default = ["arm64"] } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/lambda/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "aws_region" { description = "AWS region to deploy the resources to" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } variable "lambda_role_arn" { description = "Lambda function role ARN" type = string } variable "s3_bucket_name" { description = "S3 bucket name" type = string } variable "dynamodb_table_name" { description = "DynamoDB table name" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/lambda/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/s3/main.tf ================================================ resource "aws_s3_bucket" "static_assets" { bucket = "${var.name}-static-assets" force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/s3/outputs.tf ================================================ output "name" { value = aws_s3_bucket.static_assets.bucket } output "arn" { value = aws_s3_bucket.static_assets.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/s3/vars-optional.tf ================================================ variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/s3/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/catalog/modules/s3/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/.auto.tfvars.example ================================================ # Example configuration file - copy to terraform.tfvars and update values # Required: Name used for all resources (must be unique) name = "best-cat-2025-07-31-01" # Required: Path to your Lambda function zip file lambda_zip_file = "../../../dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/backend.tf ================================================ terraform { backend "s3" { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/main.tf ================================================ module "dev" { source = "../catalog/modules/best_cat" name = "${var.name}-dev" aws_region = var.aws_region lambda_zip_file = var.lambda_zip_file force_destroy = var.force_destroy } module "prod" { source = "../catalog/modules/best_cat" name = var.name aws_region = var.aws_region lambda_zip_file = var.lambda_zip_file force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/moved.tf ================================================ moved { from = module.ddb.aws_dynamodb_table.asset_metadata to = module.prod.module.ddb.aws_dynamodb_table.asset_metadata } moved { from = module.iam.aws_iam_policy.lambda_basic_execution to = module.prod.module.iam.aws_iam_policy.lambda_basic_execution } moved { from = module.iam.aws_iam_policy.lambda_dynamodb to = module.prod.module.iam.aws_iam_policy.lambda_dynamodb } moved { from = module.iam.aws_iam_policy.lambda_s3_read to = module.prod.module.iam.aws_iam_policy.lambda_s3_read } moved { from = module.iam.aws_iam_role.lambda_role to = module.prod.module.iam.aws_iam_role.lambda_role } moved { from = module.iam.aws_iam_role_policy_attachment.lambda_basic_execution to = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = module.iam.aws_iam_role_policy_attachment.lambda_dynamodb to = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = module.iam.aws_iam_role_policy_attachment.lambda_s3_read to = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = module.lambda.aws_lambda_function.main to = module.prod.module.lambda.aws_lambda_function.main } moved { from = module.lambda.aws_lambda_function_url.main to = module.prod.module.lambda.aws_lambda_function_url.main } moved { from = module.s3.aws_s3_bucket.static_assets to = module.prod.module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/outputs.tf ================================================ output "dev_lambda_function_url" { description = "URL of the Lambda function" value = module.dev.lambda_function_url } output "dev_s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.dev.s3_bucket_name } output "prod_lambda_function_url" { description = "URL of the Lambda function" value = module.prod.lambda_function_url } output "prod_s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.prod.s3_bucket_name } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/providers.tf ================================================ provider "aws" { region = var.aws_region } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-3-adding-dev/live/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/best_cat/main.tf ================================================ module "s3" { source = "../s3" name = var.name force_destroy = var.force_destroy } module "ddb" { source = "../ddb" name = var.name } module "iam" { source = "../iam" name = var.name aws_region = var.aws_region s3_bucket_arn = module.s3.arn dynamodb_table_arn = module.ddb.arn } module "lambda" { source = "../lambda" name = var.name aws_region = var.aws_region s3_bucket_name = module.s3.name dynamodb_table_name = module.ddb.name lambda_zip_file = var.lambda_zip_file lambda_role_arn = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/best_cat/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.lambda.url } output "lambda_function_name" { description = "Name of the Lambda function" value = module.lambda.name } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.s3.name } output "s3_bucket_arn" { description = "ARN of the S3 bucket for static assets" value = module.s3.arn } output "dynamodb_table_name" { description = "Name of the DynamoDB table for asset metadata" value = module.ddb.name } output "dynamodb_table_arn" { description = "ARN of the DynamoDB table for asset metadata" value = module.ddb.arn } output "lambda_role_arn" { description = "ARN of the Lambda execution role" value = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/best_cat/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/best_cat/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/ddb/main.tf ================================================ resource "aws_dynamodb_table" "asset_metadata" { name = "${var.name}-asset-metadata" billing_mode = "PAY_PER_REQUEST" hash_key = "image_id" attribute { name = "image_id" type = "S" } tags = { Name = "${var.name}-asset-metadata" } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/ddb/outputs.tf ================================================ output "name" { value = aws_dynamodb_table.asset_metadata.name } output "arn" { value = aws_dynamodb_table.asset_metadata.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/ddb/vars-required.tf ================================================ variable "name" { description = "The name of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/ddb/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/iam/data.tf ================================================ data "aws_caller_identity" "current" {} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/iam/main.tf ================================================ resource "aws_iam_role" "lambda_role" { name = "${var.name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_policy" "lambda_s3_read" { name = "${var.name}-lambda-s3-read" description = "Policy for Lambda to read from S3 bucket" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "s3:GetObject", "s3:ListBucket" ] Resource = [ var.s3_bucket_arn, "${var.s3_bucket_arn}/*" ] } ] }) } resource "aws_iam_policy" "lambda_dynamodb" { name = "${var.name}-lambda-dynamodb" description = "Policy for Lambda to read/write to DynamoDB table" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan" ] Resource = var.dynamodb_table_arn } ] }) } resource "aws_iam_policy" "lambda_basic_execution" { name = "${var.name}-lambda-basic-execution" description = "Policy for Lambda basic execution (CloudWatch logs)" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" } ] }) } resource "aws_iam_role_policy_attachment" "lambda_s3_read" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_s3_read.arn } resource "aws_iam_role_policy_attachment" "lambda_dynamodb" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_dynamodb.arn } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_basic_execution.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/iam/outputs.tf ================================================ output "name" { value = aws_iam_role.lambda_role.name } output "arn" { value = aws_iam_role.lambda_role.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/iam/vars-required.tf ================================================ variable "name" { description = "The name of the IAM role" type = string } variable "aws_region" { description = "The AWS region to deploy the resources to" type = string } variable "s3_bucket_arn" { description = "The ARN of the S3 bucket" type = string } variable "dynamodb_table_arn" { description = "The ARN of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/iam/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/lambda/main.tf ================================================ resource "aws_lambda_function" "main" { function_name = "${var.name}-function" filename = var.lambda_zip_file source_code_hash = filebase64sha256(var.lambda_zip_file) role = var.lambda_role_arn handler = var.lambda_handler runtime = var.lambda_runtime timeout = var.lambda_timeout memory_size = var.lambda_memory_size architectures = var.lambda_architectures environment { variables = { S3_BUCKET_NAME = var.s3_bucket_name DYNAMODB_TABLE_NAME = var.dynamodb_table_name } } } resource "aws_lambda_function_url" "main" { function_name = aws_lambda_function.main.function_name authorization_type = "NONE" } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/lambda/outputs.tf ================================================ output "name" { value = aws_lambda_function.main.function_name } output "arn" { value = aws_lambda_function.main.arn } output "url" { value = aws_lambda_function_url.main.function_url } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/lambda/vars-optional.tf ================================================ variable "lambda_runtime" { description = "Lambda function runtime" type = string default = "nodejs22.x" } variable "lambda_handler" { description = "Lambda function handler" type = string default = "index.handler" } variable "lambda_timeout" { description = "Lambda function timeout in seconds" type = number default = 30 } variable "lambda_memory_size" { description = "Lambda function memory size in MB" type = number default = 128 } variable "lambda_architectures" { description = "Lambda function architectures" type = list(string) default = ["arm64"] } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/lambda/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "aws_region" { description = "AWS region to deploy the resources to" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } variable "lambda_role_arn" { description = "Lambda function role ARN" type = string } variable "s3_bucket_name" { description = "S3 bucket name" type = string } variable "dynamodb_table_name" { description = "DynamoDB table name" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/lambda/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/s3/main.tf ================================================ resource "aws_s3_bucket" "static_assets" { bucket = "${var.name}-static-assets" force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/s3/outputs.tf ================================================ output "name" { value = aws_s3_bucket.static_assets.bucket } output "arn" { value = aws_s3_bucket.static_assets.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/s3/vars-optional.tf ================================================ variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/s3/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/catalog/modules/s3/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/.auto.tfvars.example ================================================ # Example configuration file - copy to terraform.tfvars and update values # Required: Name used for all resources (must be unique) name = "best-cat-2025-07-31-01-dev" # Required: Path to your Lambda function zip file lambda_zip_file = "../../../../dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/backend.tf ================================================ terraform { backend "s3" { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "dev/tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/main.tf ================================================ module "main" { source = "../../catalog/modules/best_cat" name = var.name aws_region = var.aws_region lambda_zip_file = var.lambda_zip_file force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/moved.tf ================================================ moved { from = module.dev.module.ddb.aws_dynamodb_table.asset_metadata to = module.main.module.ddb.aws_dynamodb_table.asset_metadata } moved { from = module.dev.module.iam.aws_iam_policy.lambda_basic_execution to = module.main.module.iam.aws_iam_policy.lambda_basic_execution } moved { from = module.dev.module.iam.aws_iam_policy.lambda_dynamodb to = module.main.module.iam.aws_iam_policy.lambda_dynamodb } moved { from = module.dev.module.iam.aws_iam_policy.lambda_s3_read to = module.main.module.iam.aws_iam_policy.lambda_s3_read } moved { from = module.dev.module.iam.aws_iam_role.lambda_role to = module.main.module.iam.aws_iam_role.lambda_role } moved { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_s3_read to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = module.dev.module.lambda.aws_lambda_function.main to = module.main.module.lambda.aws_lambda_function.main } moved { from = module.dev.module.lambda.aws_lambda_function_url.main to = module.main.module.lambda.aws_lambda_function_url.main } moved { from = module.dev.module.s3.aws_s3_bucket.static_assets to = module.main.module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.main.lambda_function_url } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.main.s3_bucket_name } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/providers.tf ================================================ provider "aws" { region = var.aws_region } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/removed.tf ================================================ removed { from = module.prod.module.s3.aws_s3_bucket.static_assets lifecycle { destroy = false } } removed { from = module.prod.module.ddb.aws_dynamodb_table.asset_metadata lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_role.lambda_role lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_policy.lambda_s3_read lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_policy.lambda_dynamodb lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_policy.lambda_basic_execution lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_s3_read lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb lifecycle { destroy = false } } removed { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution lifecycle { destroy = false } } removed { from = module.prod.module.lambda.aws_lambda_function.main lifecycle { destroy = false } } removed { from = module.prod.module.lambda.aws_lambda_function_url.main lifecycle { destroy = false } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/dev/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/.auto.tfvars.example ================================================ # Example configuration file - copy to terraform.tfvars and update values # Required: Name used for all resources (must be unique) name = "best-cat-2025-07-31-01" # Required: Path to your Lambda function zip file lambda_zip_file = "../../../../dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/backend.tf ================================================ terraform { backend "s3" { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "prod/tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/main.tf ================================================ module "main" { source = "../../catalog/modules/best_cat" name = var.name aws_region = var.aws_region lambda_zip_file = var.lambda_zip_file force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/moved.tf ================================================ moved { from = module.prod.module.ddb.aws_dynamodb_table.asset_metadata to = module.main.module.ddb.aws_dynamodb_table.asset_metadata } moved { from = module.prod.module.iam.aws_iam_policy.lambda_basic_execution to = module.main.module.iam.aws_iam_policy.lambda_basic_execution } moved { from = module.prod.module.iam.aws_iam_policy.lambda_dynamodb to = module.main.module.iam.aws_iam_policy.lambda_dynamodb } moved { from = module.prod.module.iam.aws_iam_policy.lambda_s3_read to = module.main.module.iam.aws_iam_policy.lambda_s3_read } moved { from = module.prod.module.iam.aws_iam_role.lambda_role to = module.main.module.iam.aws_iam_role.lambda_role } moved { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = module.prod.module.iam.aws_iam_role_policy_attachment.lambda_s3_read to = module.main.module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = module.prod.module.lambda.aws_lambda_function.main to = module.main.module.lambda.aws_lambda_function.main } moved { from = module.prod.module.lambda.aws_lambda_function_url.main to = module.main.module.lambda.aws_lambda_function_url.main } moved { from = module.prod.module.s3.aws_s3_bucket.static_assets to = module.main.module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.main.lambda_function_url } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.main.s3_bucket_name } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/providers.tf ================================================ provider "aws" { region = var.aws_region } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/removed.tf ================================================ removed { from = module.dev.module.s3.aws_s3_bucket.static_assets lifecycle { destroy = false } } removed { from = module.dev.module.ddb.aws_dynamodb_table.asset_metadata lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_role.lambda_role lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_policy.lambda_s3_read lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_policy.lambda_dynamodb lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_policy.lambda_basic_execution lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_s3_read lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb lifecycle { destroy = false } } removed { from = module.dev.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution lifecycle { destroy = false } } removed { from = module.dev.module.lambda.aws_lambda_function.main lifecycle { destroy = false } } removed { from = module.dev.module.lambda.aws_lambda_function_url.main lifecycle { destroy = false } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-4-breaking-the-terralith/live/prod/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/best_cat/main.tf ================================================ module "s3" { source = "../s3" name = var.name force_destroy = var.force_destroy } module "ddb" { source = "../ddb" name = var.name } module "iam" { source = "../iam" name = var.name aws_region = var.aws_region s3_bucket_arn = module.s3.arn dynamodb_table_arn = module.ddb.arn } module "lambda" { source = "../lambda" name = var.name aws_region = var.aws_region s3_bucket_name = module.s3.name dynamodb_table_name = module.ddb.name lambda_zip_file = var.lambda_zip_file lambda_role_arn = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/best_cat/outputs.tf ================================================ output "lambda_function_url" { description = "URL of the Lambda function" value = module.lambda.url } output "lambda_function_name" { description = "Name of the Lambda function" value = module.lambda.name } output "s3_bucket_name" { description = "Name of the S3 bucket for static assets" value = module.s3.name } output "s3_bucket_arn" { description = "ARN of the S3 bucket for static assets" value = module.s3.arn } output "dynamodb_table_name" { description = "Name of the DynamoDB table for asset metadata" value = module.ddb.name } output "dynamodb_table_arn" { description = "ARN of the DynamoDB table for asset metadata" value = module.ddb.arn } output "lambda_role_arn" { description = "ARN of the Lambda execution role" value = module.iam.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/best_cat/vars-optional.tf ================================================ variable "aws_region" { description = "AWS region for all resources" type = string default = "us-east-1" } variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/best_cat/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/ddb/main.tf ================================================ resource "aws_dynamodb_table" "asset_metadata" { name = "${var.name}-asset-metadata" billing_mode = "PAY_PER_REQUEST" hash_key = "image_id" attribute { name = "image_id" type = "S" } tags = { Name = "${var.name}-asset-metadata" } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/ddb/outputs.tf ================================================ output "name" { value = aws_dynamodb_table.asset_metadata.name } output "arn" { value = aws_dynamodb_table.asset_metadata.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/ddb/vars-required.tf ================================================ variable "name" { description = "The name of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/ddb/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/iam/data.tf ================================================ data "aws_caller_identity" "current" {} ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/iam/main.tf ================================================ resource "aws_iam_role" "lambda_role" { name = "${var.name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_policy" "lambda_s3_read" { name = "${var.name}-lambda-s3-read" description = "Policy for Lambda to read from S3 bucket" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "s3:GetObject", "s3:ListBucket" ] Resource = [ var.s3_bucket_arn, "${var.s3_bucket_arn}/*" ] } ] }) } resource "aws_iam_policy" "lambda_dynamodb" { name = "${var.name}-lambda-dynamodb" description = "Policy for Lambda to read/write to DynamoDB table" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan" ] Resource = var.dynamodb_table_arn } ] }) } resource "aws_iam_policy" "lambda_basic_execution" { name = "${var.name}-lambda-basic-execution" description = "Policy for Lambda basic execution (CloudWatch logs)" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" } ] }) } resource "aws_iam_role_policy_attachment" "lambda_s3_read" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_s3_read.arn } resource "aws_iam_role_policy_attachment" "lambda_dynamodb" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_dynamodb.arn } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = aws_iam_policy.lambda_basic_execution.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/iam/outputs.tf ================================================ output "name" { value = aws_iam_role.lambda_role.name } output "arn" { value = aws_iam_role.lambda_role.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/iam/vars-required.tf ================================================ variable "name" { description = "The name of the IAM role" type = string } variable "aws_region" { description = "The AWS region to deploy the resources to" type = string } variable "s3_bucket_arn" { description = "The ARN of the S3 bucket" type = string } variable "dynamodb_table_arn" { description = "The ARN of the DynamoDB table" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/iam/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/lambda/main.tf ================================================ resource "aws_lambda_function" "main" { function_name = "${var.name}-function" filename = var.lambda_zip_file source_code_hash = filebase64sha256(var.lambda_zip_file) role = var.lambda_role_arn handler = var.lambda_handler runtime = var.lambda_runtime timeout = var.lambda_timeout memory_size = var.lambda_memory_size architectures = var.lambda_architectures environment { variables = { S3_BUCKET_NAME = var.s3_bucket_name DYNAMODB_TABLE_NAME = var.dynamodb_table_name } } } resource "aws_lambda_function_url" "main" { function_name = aws_lambda_function.main.function_name authorization_type = "NONE" } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/lambda/outputs.tf ================================================ output "name" { value = aws_lambda_function.main.function_name } output "arn" { value = aws_lambda_function.main.arn } output "url" { value = aws_lambda_function_url.main.function_url } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/lambda/vars-optional.tf ================================================ variable "lambda_runtime" { description = "Lambda function runtime" type = string default = "nodejs22.x" } variable "lambda_handler" { description = "Lambda function handler" type = string default = "index.handler" } variable "lambda_timeout" { description = "Lambda function timeout in seconds" type = number default = 30 } variable "lambda_memory_size" { description = "Lambda function memory size in MB" type = number default = 128 } variable "lambda_architectures" { description = "Lambda function architectures" type = list(string) default = ["arm64"] } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/lambda/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } variable "aws_region" { description = "AWS region to deploy the resources to" type = string } variable "lambda_zip_file" { description = "Path to the Lambda function zip file" type = string } variable "lambda_role_arn" { description = "Lambda function role ARN" type = string } variable "s3_bucket_name" { description = "S3 bucket name" type = string } variable "dynamodb_table_name" { description = "DynamoDB table name" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/lambda/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/s3/main.tf ================================================ resource "aws_s3_bucket" "static_assets" { bucket = "${var.name}-static-assets" force_destroy = var.force_destroy } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/s3/outputs.tf ================================================ output "name" { value = aws_s3_bucket.static_assets.bucket } output "arn" { value = aws_s3_bucket.static_assets.arn } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/s3/vars-optional.tf ================================================ variable "force_destroy" { description = "Force destroy S3 buckets (only set to true for testing or cleanup of demo environments)" type = bool default = false } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/s3/vars-required.tf ================================================ variable "name" { description = "Name used for all resources" type = string } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/catalog/modules/s3/versions.tf ================================================ terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/live/dev/moved.tf ================================================ moved { from = module.main.module.ddb.aws_dynamodb_table.asset_metadata to = module.ddb.aws_dynamodb_table.asset_metadata } moved { from = module.main.module.iam.aws_iam_policy.lambda_basic_execution to = module.iam.aws_iam_policy.lambda_basic_execution } moved { from = module.main.module.iam.aws_iam_policy.lambda_dynamodb to = module.iam.aws_iam_policy.lambda_dynamodb } moved { from = module.main.module.iam.aws_iam_policy.lambda_s3_read to = module.iam.aws_iam_policy.lambda_s3_read } moved { from = module.main.module.iam.aws_iam_role.lambda_role to = module.iam.aws_iam_role.lambda_role } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution to = module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb to = module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_s3_read to = module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = module.main.module.lambda.aws_lambda_function.main to = module.lambda.aws_lambda_function.main } moved { from = module.main.module.lambda.aws_lambda_function_url.main to = module.lambda.aws_lambda_function_url.main } moved { from = module.main.module.s3.aws_s3_bucket.static_assets to = module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/live/dev/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "../../catalog/modules//best_cat" } inputs = { name = "best-cat-2025-09-24-2359-dev" lambda_zip_file = "${get_repo_root()}/dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/live/prod/moved.tf ================================================ moved { from = module.main.module.ddb.aws_dynamodb_table.asset_metadata to = module.ddb.aws_dynamodb_table.asset_metadata } moved { from = module.main.module.iam.aws_iam_policy.lambda_basic_execution to = module.iam.aws_iam_policy.lambda_basic_execution } moved { from = module.main.module.iam.aws_iam_policy.lambda_dynamodb to = module.iam.aws_iam_policy.lambda_dynamodb } moved { from = module.main.module.iam.aws_iam_policy.lambda_s3_read to = module.iam.aws_iam_policy.lambda_s3_read } moved { from = module.main.module.iam.aws_iam_role.lambda_role to = module.iam.aws_iam_role.lambda_role } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_basic_execution to = module.iam.aws_iam_role_policy_attachment.lambda_basic_execution } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_dynamodb to = module.iam.aws_iam_role_policy_attachment.lambda_dynamodb } moved { from = module.main.module.iam.aws_iam_role_policy_attachment.lambda_s3_read to = module.iam.aws_iam_role_policy_attachment.lambda_s3_read } moved { from = module.main.module.lambda.aws_lambda_function.main to = module.lambda.aws_lambda_function.main } moved { from = module.main.module.lambda.aws_lambda_function_url.main to = module.lambda.aws_lambda_function_url.main } moved { from = module.main.module.s3.aws_s3_bucket.static_assets to = module.s3.aws_s3_bucket.static_assets } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/live/prod/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "../../catalog/modules//best_cat" } inputs = { name = "best-cat-2025-09-24-2359" lambda_zip_file = "${get_repo_root()}/dist/best-cat.zip" # Optional: Force destroy S3 buckets even when they have objects in them. # You're generally advised not to do this with important infrastructure, # however this makes testing and cleanup easier for this guide. force_destroy = true } ================================================ FILE: docs/src/fixtures/terralith-to-terragrunt/walkthrough/step-5-adding-terragrunt/live/root.hcl ================================================ remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite" } config = { bucket = "terragrunt-to-terralith-tfstate-2025-09-24-2359" key = "${path_relative_to_include()}/tofu.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } generate "providers" { path = "providers.tf" if_exists = "overwrite_terragrunt" contents = < {title} ================================================ FILE: docs/src/lib/commands/headings/index.ts ================================================ import { getEntry, type CollectionEntry } from 'astro:content'; export async function getHeadings( command: CollectionEntry<'commands'>, ): Promise<{ depth: number; slug: string; text: string }[]> { const headings: { depth: number; slug: string; text: string }[] = []; headings.push({ depth: 2, slug: 'usage', text: 'Usage' }); if (command.data.examples) { headings.push({ depth: 2, slug: 'examples', text: 'Examples' }); } const h2HeadingsLines = command.body?.match(/## (.*)/g); const h2Headings = h2HeadingsLines?.map((line) => line.replace(/## /g, '')); const h3HeadingsLines = command.body?.match(/### (.*)/g); const h3Headings = h3HeadingsLines?.map((line) => line.replace(/### /g, '')); if (h2Headings) { h2Headings.forEach((text) => { const slug = text.toLowerCase().replace(/ /g, '-'); headings.push({ depth: 2, slug, text }); }); } if (h3Headings) { h3Headings.forEach((text) => { const slug = text.toLowerCase().replace(/ /g, '-'); headings.push({ depth: 3, slug, text }); }); } if (command.data.flags) { headings.push({ depth: 2, slug: 'flags', text: 'Flags' }); const flags = await Promise.all(command.data.flags.map(async (flagName: string) => { const flag = (await getEntry('flags', flagName))!; return { depth: 3, slug: flag.data.name, text: `--${flag.data.name}`, }; })); headings.push(...flags); } return headings; }; ================================================ FILE: docs/src/lib/commands/sidebar/index.ts ================================================ import type { CollectionEntry } from "astro:content"; import type { SidebarItem } from "node_modules/@astrojs/starlight/schemas/sidebar"; import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; import { sidebar as sidebarTemplate } from '../../../../astro.config.mjs'; function createCommandSidebarItem(command: CollectionEntry<'commands'>): SidebarItem & { originalPath: string } { const data = command.data; const sidebarItem = { label: data.name, slug: `reference/cli/commands/${data.path}`, originalPath: data.path, } as SidebarItem & { originalPath: string }; if (data.experiment) { sidebarItem.badge = { variant: 'tip', text: 'exp', }; } return sidebarItem; } function organizeCommandsIntoGroups(flatCommandItems: (SidebarItem & { originalPath: string })[]): SidebarItem[] { const commandItems: SidebarItem[] = []; const groupedCommands: Record = {}; flatCommandItems.forEach((item) => { const parts = item.originalPath.split('/'); if (parts.length === 1) { // Root-level command const { originalPath, ...cleanItem } = item; commandItems.push(cleanItem); } else { // Nested command const groupName = parts[0]; if (!groupedCommands[groupName]) { groupedCommands[groupName] = []; commandItems.push({ label: groupName, collapsed: true, translations: {}, items: groupedCommands[groupName] }); } const { originalPath, ...cleanItem } = item; groupedCommands[groupName].push(cleanItem); } }); return commandItems; } function insertCommandsIntoSidebar( sidebar: SidebarItem[], commandItems: SidebarItem[] ): void { const referenceSection = sidebar.find(item => typeof item === 'object' && 'label' in item && item.label === 'Reference' ) as { items: SidebarItem[] }; if (!referenceSection?.items) return; const cliSection = referenceSection.items.find(item => typeof item === 'object' && 'label' in item && item.label === 'CLI' ) as { items: SidebarItem[] }; if (!cliSection?.items) return; // Remove existing Commands section cliSection.items = cliSection.items.filter(item => !(typeof item === 'object' && 'label' in item && item.label === 'Commands') ); // Insert new Commands section after Overview const overviewIndex = cliSection.items.findIndex(item => typeof item === 'object' && 'label' in item && item.label === 'Overview' ); if (overviewIndex !== -1) { cliSection.items.splice(overviewIndex + 1, 0, { label: 'Commands', collapsed: true, translations: {}, items: commandItems }); } } function populateAutogeneratedSections(items: SidebarItem[]) { for (const item of items) { if (typeof item === 'object' && 'autogenerate' in item) { const dirPath = path.join(process.cwd(), 'src/content/docs', item.autogenerate.directory); if (fs.existsSync(dirPath)) { const files = fs.readdirSync(dirPath) .filter(file => file.endsWith('.mdx') || file.endsWith('.md')); (item as SidebarItem & { items: SidebarItem[] }).items = files.map(file => { const content = fs.readFileSync(path.join(dirPath, file), 'utf-8'); const { data } = matter(content); return { label: data.title as string, translations: {}, slug: data.slug as string, attrs: {} }; }); delete (item as any).autogenerate; } } if (typeof item === 'object' && 'items' in item) { populateAutogeneratedSections(item.items); } } } export async function getSidebar(commands: CollectionEntry<'commands'>[]): Promise { // Deep clone the sidebar template to avoid mutations const sidebar = JSON.parse(JSON.stringify(sidebarTemplate)); // Create flat list of command items const flatCommandItems = commands .sort((a, b) => a.data.sidebar.order - b.data.sidebar.order) .map(createCommandSidebarItem); // Organize commands into nested groups const commandItems = organizeCommandsIntoGroups(flatCommandItems); // Insert commands into the sidebar insertCommandsIntoSidebar(sidebar, commandItems); // Handle autogenerated sections populateAutogeneratedSections(sidebar); return sidebar; } ================================================ FILE: docs/src/lib/github.ts ================================================ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour in milliseconds const FETCH_TIMEOUT_MS = 10 * 1000; // 10 second timeout for API requests interface CacheEntry { data: T; timestamp: number; } const cache = new Map>(); /** * Memoized fetch that caches results for 1 hour. * Used to avoid rate limiting on GitHub API calls during builds. */ async function memoizedFetch( cacheKey: string, fetchFn: () => Promise ): Promise { const now = Date.now(); const cached = cache.get(cacheKey) as CacheEntry | undefined; if (cached && now - cached.timestamp < CACHE_TTL_MS) { return cached.data; } const data = await fetchFn(); // Only cache successful (non-null) results to allow retries on failures if (data !== null) { cache.set(cacheKey, { data, timestamp: now }); } return data; } interface GitHubRepoResponse { stargazers_count: number; [key: string]: unknown; } interface GitHubReleaseResponse { tag_name: string; name: string; html_url: string; published_at: string; [key: string]: unknown; } /** * Fetches GitHub repository data with memoization. * Results are cached for 1 hour to avoid rate limiting. */ export async function getGitHubRepo( owner: string, repo: string ): Promise { const cacheKey = `repo:${owner}/${repo}`; try { return await memoizedFetch(cacheKey, async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}`, { headers: { 'User-Agent': 'Terragrunt-Docs', }, signal: controller.signal, } ); if (!response.ok) { console.error( `Failed to fetch GitHub repo ${owner}/${repo}:`, response.status, await response.text() ); return null; } return response.json(); } finally { clearTimeout(timeoutId); } }); } catch (error) { console.error(`Error fetching GitHub repo ${owner}/${repo}:`, error); return null; } } /** * Fetches the latest release for a GitHub repository with memoization. * Results are cached for 1 hour to avoid rate limiting. */ export async function getLatestRelease( owner: string, repo: string ): Promise { const cacheKey = `release:${owner}/${repo}`; try { return await memoizedFetch(cacheKey, async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/releases/latest`, { headers: { 'User-Agent': 'Terragrunt-Docs', }, signal: controller.signal, } ); if (!response.ok) { console.error( `Failed to fetch latest release for ${owner}/${repo}:`, response.status, await response.text() ); return null; } return response.json(); } finally { clearTimeout(timeoutId); } }); } catch (error) { console.error(`Error fetching latest release for ${owner}/${repo}:`, error); return null; } } /** * Formats a star count for display (e.g., 8600 -> "8.6k") */ export function formatStarCount(stars: number): string { return (stars / 1000).toFixed(1) + 'k'; } ================================================ FILE: docs/src/lib/utils.ts ================================================ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" // Shadcn has a convention of using the cn function to merge classes // https://github.com/shadcn-ui/ui/blob/main/apps/www/lib/utils.ts#L5 export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ================================================ FILE: docs/src/pages/api/v1/compatibility/[tool].ts ================================================ import type { APIRoute, GetStaticPaths } from 'astro'; import { getCollection } from 'astro:content'; export const prerender = true; export const getStaticPaths: GetStaticPaths = () => [ { params: { tool: 'index' } }, { params: { tool: 'opentofu' } }, { params: { tool: 'terraform' } }, ]; export const GET: APIRoute = async ({ params }) => { const tool = params.tool === 'index' ? undefined : params.tool; const entries = (await getCollection('compatibility')) .filter(e => !tool || e.data.tool === tool) .sort((a, b) => { if (a.data.tool !== b.data.tool) { return a.data.tool === 'opentofu' ? -1 : 1; } return b.data.order - a.data.order; }) .map(e => ({ tool: e.data.tool, version: e.data.version, terragrunt_min: e.data.terragrunt_min, terragrunt_max: e.data.terragrunt_max, })); return new Response(JSON.stringify(entries), { headers: { 'Content-Type': 'application/json' }, }); }; ================================================ FILE: docs/src/pages/index.astro ================================================ --- import BaseLayout from '@layouts/BaseLayout.astro'; import ConsistencySection from '@components/dv-ConsistencySection.astro'; import DrySection from '@components/dv-DrySection.astro'; import FeaturedBrands from '@components/dv-FeaturedBrands.astro'; import Footer from '@components/dv-Footer.astro'; import Header from '@components/Header.astro'; import Hero from '@components/dv-Hero.astro'; import OrchestrateSection from '@components/dv-OrchestrateSection.astro'; import PageContainer from '@components/PageContainer.astro'; import PetAdvertise from '@components/dv-PetAdvertise.astro'; import Testimonials from '@components/dv-Testimonials.astro'; import TopBanner from '@components/TopBanner.astro'; import '@styles/global.css'; import '@styles/custom-page.css'; ---
================================================ FILE: docs/src/pages/reference/cli/commands/[...slug].astro ================================================ --- import Command from '@components/Command.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import { getCollection } from 'astro:content'; import { getHeadings } from '@lib/commands/headings'; import { getSidebar } from '@lib/commands/sidebar'; import { string } from 'astro:schema'; export const prerender = true; export async function getStaticPaths() { const commands = await getCollection('commands'); const sidebar = await getSidebar(commands); return Promise.all(commands.map(async (command) => { const headings = await getHeadings(command); const data = command.data; return { params: { slug: data.path, }, props: { name: data.name, path: data.path, description: data.description, experiment: data.experiment, examples: data.examples, headings: headings, sidebar: sidebar, }, } })); } const { name, path, description, headings, sidebar } = Astro.props; --- ================================================ FILE: docs/src/styles/global.css ================================================ @layer base, starlight, theme, components, utilities; @import '@astrojs/starlight-tailwind'; @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/utilities.css" layer(utilities); @import "./lists.css" layer(components); /* Starlight Fixes */ @import "./starlight-search.css" layer(components); @import "./starlight-right-sidebar.css" layer(components); @font-face { font-family: 'Inter'; src: url('/fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; } @font-face { font-family: 'Inter'; src: url('/fonts/Inter-Italic-VariableFont_opsz,wght.ttf') format('truetype'); font-weight: 100 900; font-style: italic; font-display: swap; } @font-face { font-family: 'Geist Mono'; src: url('/fonts/GeistMono-VariableFont_wght.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; } @theme { /* Fonts */ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-mono: 'Geist Mono', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; /* Buttons */ --color-button-bg: #EEECF7; --color-checkout-radio: #eeecf6; --color-checkout-radio-active: #4a31c8; /* Colors Nav */ --color-nav-link: #A0A1AC; --color-nav-link-hover: #e8eefc; /* Colors Text */ --color-primary: #0F0934; /* Colors Landing Page */ --color-accent: #4F2FD0; --color-accent-1: #7d5DFF; --color-accent-2: #87E0E1; --color-accent-3: #BFA6F2; --color-bg-dark: #0F1731; --color-dark-blue-1: #0F0934; --color-gray-1: #A0A1AC; --color-gray-2: #6B6C7A; --color-gray-3: #DBDBDB; --color-gray-4: #6D6F86; --color-stroke-dark: #2E375A; --color-button-primary-border: hsla(0deg, 0%, 100%, 20%); /* Colors Contact Form */ --color-contact-form-button-bg: rgba(255, 255, 255, 0.15); /* Component Colors */ --color-opacity-5: rgba(255, 255, 255, 0.05); --color-opacity-10: rgba(255, 255, 255, 0.1); /* Dialog */ --color-dialog-bg: oklch(0.92 0.004 286.32); /* Colors Terragrunt Page */ --color-feature-container: #171e3c; --color-feature-container-stroke: #7b7f94; --color-card-border: #2F3547; --color-acc-text: #F48701; --color-gray-border: #E2E3E6; --color-primary-button-light: hsl(33, 99%, 47%); /* Starlight Customizations */ --sl-color-hairline-shade: #2E375A; --sl-color-secondary: #EBEBEB; --sl-text-sm: 16px; --sl-color-stroke-dark: #2E375A; --sl-z-index-toc: 10; /* Strokes */ --color-stroke-light: #282A46; /* Surfaces */ --color-surface-1: #0F0726; --color-surface-3: #1C1833; /* Navigation */ --sl-color-bg-nav: #0F1731; --sl-color-asides-border: hsl(41, 90%, 60%); --sl-nav-height: 90px; } /* Docs Dark mode colors. */ :root { --sl-color-bg: #0F1731; --sl-color-bg-sidebar: #0F1731; --sl-color-bg-inline-code: #2b2d4c; --color-code-border: #403f64; --color-code-text: #BFA6F2; --sl-color-accent-low: #1f1d47; --sl-color-accent: #5e46e6; --sl-color-accent-high: #c0c3fa; --sl-color-black: #131824; --sl-color-docs-stroke: #2E375A; --sl-color-gray-1: #e8eefc; --sl-color-gray-2: #bbc2d4; --sl-color-gray-3: #7e8bac; --sl-color-gray-4: #4c5776; --sl-color-gray-5: #2d3754; --sl-color-gray-6: #1c2541; --color-toc-accent: #BFA6F2; --color-toc-background: rgba(191, 166, 242, 0.15); --color-toc-text: #EBEBEB; --sl-color-white: #ffffff; } /* Docs Light mode colors. */ :root[data-theme='light'] { --sl-color-bg: #ffffff; --sl-color-bg-sidebar: #ffffff; --sl-color-bg-inline-code: #F3F2FF; --color-code-border: #BDADFF; --color-code-text: #7B5AFF; --sl-color-accent-low: #d0d3fc; --sl-color-accent: #6049e8; --sl-color-accent-high: #2c2669; --sl-color-black: #ffffff; --sl-color-docs-stroke: #DBDBDB; --sl-color-gray-1: #1c2541; --sl-color-gray-2: #2d3754; --sl-color-gray-3: #4c5776; --sl-color-gray-4: #7e8bac; --sl-color-gray-5: #bbc2d4; --sl-color-gray-6: #e8eefc; --color-toc-accent: #7B5AFF; --color-toc-background: #E4E9FC; --color-toc-text: #777888; --sl-color-white: #131824; } @layer base { * { box-sizing: border-box; } body { margin: 0; padding: 0; } /* Hide scrollbar for Chrome, Safari and Opera */ .no-scrollbar::-webkit-scrollbar { display: none; } /* Hide scrollbar for IE, Edge and Firefox */ .no-scrollbar { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } @media (width <= 375px) { .primary-button, .secondary-button { padding: 10px 10px; } } } /* Handle all CSS overrides in a single layer to make it easier to manage and update. TODO: Better organize the CSS here and split into appropriate layers as needed. */ @layer components { /* Baseline styles These should really be in the base layer, but for now we want them to apply at this layer. */ h1, h2, h3, h4, h5, h6 { font-weight: 400; } /* Header */ header { background-color: transparent; border: none; height: var(--sl-nav-height); } .header { padding: 0 !important; } starlight-menu-button button { top: 92px; position: fixed; } @media (max-width: 767px) { starlight-menu-button button { top: 70px; } } .searchbar { display: flex; align-items: center; justify-content: start; gap: 8px; width: 280px; height: 40px; padding: 6px 12px; background-color: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; } .menu-icon { display: none; } @media (min-width: 768px) and (max-width: 1023px) { .menu-icon { display: inline-flex; } } /* Table of contents (right-hand side in-page guide) */ mobile-starlight-toc { display: block; } mobile-starlight-toc nav { left: 0; position: relative; top: 0; } starlight-toc h2 { background-color: var(--color-toc-background); color: var(--color-code-text); display: inline-block; font-family: var(--font-mono); font-size: 12px; text-transform: uppercase; user-select: none; padding: 4px 2px; } starlight-toc li { font-family: var(--font-sans); margin: 0.2rem; } starlight-toc li a { color: var(--color-toc-text); } starlight-toc li a[aria-current="true"], starlight-toc li a:hover { color: var(--color-toc-accent); font-weight: 500; } .main-frame { margin-top: var(--sl-nav-height); padding-top: 0; } .social-icon { color: var(--color-nav-link) !important; cursor: pointer; text-decoration: none; } .social-icon:hover { color: var(--color-nav-link-hover) !important; cursor: pointer; } /* Main body */ .sl-markdown-content h2:not(:first-child) { margin-top: 1.5em; } .sl-markdown-content .expressive-code { margin: 1.5em 0; } .sl-markdown-content aside { border-left: 5px solid var(--sl-color-asides-border); margin: 1.5em 0; } h1 code, h2 code, h3 code, h4 code, h5 code, h6 code, p code { border: 1px solid var(--color-code-border); border-radius: 6px; color: var(--color-code-text); } #starlight__sidebar ul li { margin: 0; } #starlight__sidebar ul li ul li { padding-left: 1em; } #starlight__sidebar details { padding-left: 0; margin-bottom: 10px; } #starlight__sidebar summary { margin-top: 5px; padding-left: 0; } #starlight__sidebar details details details { margin-bottom: 0; } #starlight__sidebar details details details summary { margin-top: 0; padding: 0.3em var(--sl-sidebar-item-padding-inline); } /* Open Source Cards */ .opensourcecard { border-left: 0; } .opensourcecard:last-child { border-right: 0; } /* Main pane */ .main-pane .sl-container { margin-inline: 0; } .main-pane p { line-height: 1.6; margin-bottom: 1.5rem; } .main-pane starlight-file-tree { margin-bottom: 1.8rem; } .main-pane .code { font-size: calc(14 / 16 * 1rem); } /* Sidebar */ .sidebar-pane { border-inline-end: 1px dashed var(--sl-color-docs-stroke); } .content-panel { border-top: 1px dashed var(--sl-color-docs-stroke); } .right-sidebar { border-inline-start: 1px dashed var(--sl-color-docs-stroke); } /* Cards */ article.card { background-color: transparent; border: 1px solid var(--sl-color-gray-5); margin: 1.5em 0; } } @media (width < 768px) { :root { --sl-nav-height: 125px; } } @media (1280px > width >= 768px) { :root { --sl-nav-height: 148px; } } /* FAQ */ .accordion-item:last-of-type { border-bottom: dashed 1px var(--color-gray-3); } ================================================ FILE: docs/src/styles/lists.css ================================================ /* List styling to ensure proper rendering */ ol { list-style-type: decimal; margin: 1em 0; padding-left: 3em; } ol ol { list-style-type: lower-alpha; margin: 0.5em 0; padding-left: 2.5em; } ol ol ol { list-style-type: lower-roman; margin: 0.5em 0; padding-left: 2.5em; } /* Override numbering for sl-steps lists which have their own numbering */ ol.sl-steps { list-style-type: none; } ol.sl-steps ol { list-style-type: none; } ol.sl-steps ol ol { list-style-type: none; } li { margin: 0.5em 0; line-height: 1.6; } /* Ensure list items have proper spacing and alignment */ ol li { display: list-item; text-align: left; } ================================================ FILE: docs/src/styles/starlight-right-sidebar.css ================================================ .right-sidebar-panel .sl-container { max-height: calc(100vh - 300px); min-height: 200px; overflow-y: auto; width: 100%; } ================================================ FILE: docs/src/styles/starlight-search.css ================================================ /* Search Box */ @media (width < 800px) { #search-and-buttons { width: calc(100% - 50px) !important; } } /* Search Box NOT inside Dialog */ site-search>button { display: flex; width: 100%; } site-search button svg { color: var(--color-gray-1); } /* Search Dialog Box */ site-search dialog[open] { background: var(--color-dialog-bg); border-radius: .5rem; display: flex; height: max-content; margin: 4rem auto auto; max-height: calc(100% - 8rem); max-width: 40rem; min-height: 15rem; width: 90%; } button[data-close-modal] { color: black; display: none; } site-search .dialog-frame { overflow-x: hidden; padding: 1.5rem; } #starlight__search > div:nth-of-type(2) { display: none; } site-search .pagefind-ui { color: oklch(0.37 0.013 285.805); } site-search .pagefind-ui__drawer { display: flex; flex-direction: row; flex-wrap: wrap; } site-search .pagefind-ui__results { padding-left: 0; } site-search .pagefind-ui__result { padding: 20px 10px; } /* Site Search Dark Mode */ html[data-theme="dark"] site-search input, html[data-theme="dark"] site-search .pagefind-ui__result-link { color: rgb(19, 24, 36); } /* * Starlight Overrides * * Context: Starlight components apply different styles based on dark/light mode state. * This causes visual issues because the docs pages have dark mode support, but the homepage nav does not. * This override fixes an issue on the homepage nav by styling CMD+K in a consistent way across all pages in both * modes. * * Future Considerations: * - If homepage gains dark mode support, this override may need adjustment * - Consider if a more systematic theming approach is needed long-term */ site-search button kbd { background-color: transparent; font-size: var(--sl-text-sm); gap: 0; margin: 0 0 0 auto; } .pagefind-ui__message { padding: 1.5em 0; height: auto; } #starlight__search .pagefind-ui__form:before { background-color: black !important; } input.pagefind-ui__search-input { background-color: white; color: black; border: 1px solid oklch(0.871 0.006 286.286); border-radius: 8px; max-width: 590px; padding-left: 50px; width: -webkit-fill-available; } input.pagefind-ui__search-input::placeholder { color: #666; } input.pagefind-ui__search-input:focus { outline: 2px solid #448daf; outline-offset: 2px; } .pagefind-ui__search-clear { background-color: transparent; border: none; padding: 10px; right: 0; width: 40px; } .pagefind-ui__search-clear:before { background-color: black; content: ""; display: block; height: 100%; margin-bottom: 20px; width: 100%; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E") center / 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E") center / 100% no-repeat; } #starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)), #starlight__search .pagefind-ui__result-nested, .pagefind-ui__result-title, .pagefind-ui__result-nested { background-color: transparent; } .pagefind-ui__result { background: white; color: black; border-radius: 8px; margin-bottom: 20px; } /* Fix search result icons - same approach as text fix */ .pagefind-ui__result svg, .pagefind-ui__result svg path { fill: black; } .pagefind-ui__result-link, .pagefind-ui__result-excerpt { color: black; } .pagefind-ui__result-title a { color: black; } .pagefind-ui__result-title a:hover { color: #448daf; } /* Hide Light/Dark Mode Toggle in Hamburger Menu */ div.mobile-preferences { display: none; } /* Fix to site-search button area smaller on mobile */ @media (width <= 768px) { body.tg site-search > button { width: 100%; } } ================================================ FILE: docs/tailwind.config.mjs ================================================ /* This is the global Tailwind config for both Starlight standard pages, custom pages, and components. The file contents are notably minimal because we're currently defining most of our styles in global.css. At some point, it would be good to migrate the many styles defined there into a proper Tailwind theming defined in this config. */ /** @type {import('tailwindcss').Config} */ export default { content: [ "./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}", ], theme: { extend: {}, }, plugins: [], } ================================================ FILE: docs/tests/install_test.sh ================================================ #!/usr/bin/env bash # Tests for Terragrunt install script # # Usage: # ./install_test.sh # Run all tests # ./install_test.sh --quick # Skip download tests (faster) # # Requirements: bash 3.2+ # Note: Download tests require internet connection # shellcheck disable=SC2317 # Functions are called indirectly via run_test set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_SCRIPT="${SCRIPT_DIR}/../public/install" # Test counters TESTS_RUN=0 TESTS_PASSED=0 TESTS_FAILED=0 # Colors if [[ -t 1 ]]; then RED=$'\033[0;31m' GREEN=$'\033[0;32m' YELLOW=$'\033[0;33m' NC=$'\033[0m' else RED='' GREEN='' YELLOW='' NC='' fi # --- Test Helpers --- pass() { TESTS_PASSED=$((TESTS_PASSED + 1)) printf "${GREEN}✓${NC} %s\n" "$1" return 0 } fail() { TESTS_FAILED=$((TESTS_FAILED + 1)) printf "${RED}✗${NC} %s\n" "$1" if [[ -n "${2:-}" ]]; then printf " ${RED}Error: %s${NC}\n" "$2" fi return 0 } run_test() { local name="$1" shift TESTS_RUN=$((TESTS_RUN + 1)) if "$@"; then pass "$name" return 0 else fail "$name" return 1 fi } skip_test() { printf "${YELLOW}○${NC} %s (skipped)\n" "$1" return 0 } # --- Unit Tests --- test_script_exists() { [[ -f "$INSTALL_SCRIPT" ]] } test_script_executable_syntax() { bash -n "$INSTALL_SCRIPT" } test_help_output() { local output output=$(bash "$INSTALL_SCRIPT" --help 2>&1) [[ "$output" == *"Terragrunt Installer"* ]] && [[ "$output" == *"--version"* ]] && [[ "$output" == *"--dir"* ]] && [[ "$output" == *"--force"* ]] && [[ "$output" == *"--no-verify-sig"* ]] && [[ "$output" == *"--verify-cosign"* ]] && [[ "$output" == *"--no-verify"* ]] } test_help_exit_code() { bash "$INSTALL_SCRIPT" --help >/dev/null 2>&1 } test_invalid_option_fails() { ! bash "$INSTALL_SCRIPT" --invalid-option 2>/dev/null } test_missing_version_arg_fails() { ! bash "$INSTALL_SCRIPT" -v 2>/dev/null } test_missing_dir_arg_fails() { ! bash "$INSTALL_SCRIPT" -d 2>/dev/null } # Test OS detection by sourcing functions test_os_detection() { local os os=$(uname -s) case "$os" in Darwin|Linux) return 0 ;; *) return 1 ;; esac } # Test arch detection test_arch_detection() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64|aarch64|arm64|i386|i686) return 0 ;; *) return 1 ;; esac } # Test that sha256sum or shasum exists test_checksum_tool_exists() { command -v sha256sum &>/dev/null || command -v shasum &>/dev/null } # Test curl exists test_curl_exists() { command -v curl &>/dev/null } # --- Network Connectivity Check --- check_network_connectivity() { # Quick check if we can reach GitHub curl -fsI --connect-timeout 5 "https://github.com" >/dev/null 2>&1 } # --- Integration Tests (require network) --- test_fetch_latest_version() { local version # Use redirect method (same as install) local redirect_url redirect_url=$(curl -fsI "https://github.com/gruntwork-io/terragrunt/releases/latest" 2>/dev/null | grep -i '^location:' | tr -d '\r') version=$(echo "$redirect_url" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]] } test_install_specific_version() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig >/dev/null 2>&1 && [[ -f "$tmpdir/terragrunt" ]] && [[ -x "$tmpdir/terragrunt" ]] && "$tmpdir/terragrunt" --version 2>&1 | grep -q "v0.72.5" } test_install_rc_version() { # Requires gpg (GPG signature verification is default) command -v gpg &>/dev/null || { echo "gpg required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.98.0-rc2026011601 >/dev/null 2>&1 && [[ -f "$tmpdir/terragrunt" ]] && [[ -x "$tmpdir/terragrunt" ]] && "$tmpdir/terragrunt" --version 2>&1 | grep -q "v0.98.0-rc2026011601" } test_install_latest_version() { # Requires gpg (GPG signature verification is default) command -v gpg &>/dev/null || { echo "gpg required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN bash "$INSTALL_SCRIPT" -d "$tmpdir" >/dev/null 2>&1 && [[ -f "$tmpdir/terragrunt" ]] && [[ -x "$tmpdir/terragrunt" ]] && "$tmpdir/terragrunt" --version 2>&1 | grep -qE "^terragrunt version v[0-9]+" } test_install_already_exists_fails() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # First install bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig >/dev/null 2>&1 || return 1 # Second install without --force should fail ! bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig 2>/dev/null } test_install_force_overwrites() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # First install bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig >/dev/null 2>&1 || return 1 # Second install with --force should succeed bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --force --no-verify-sig >/dev/null 2>&1 } test_install_creates_directory() { local tmpdir tmpdir=$(mktemp -d) local install_dir="${tmpdir}/new/nested/dir" # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # Should auto-create the directory bash "$INSTALL_SCRIPT" -d "$install_dir" -v v0.72.5 --no-verify-sig >/dev/null 2>&1 && [[ -f "${install_dir}/terragrunt" ]] } test_install_invalid_version_fails() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN ! bash "$INSTALL_SCRIPT" -d "$tmpdir" -v invalid --no-verify-sig 2>/dev/null } test_install_no_verify() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify --no-verify-sig 2>&1) [[ "$output" == *"Skipping checksum verification"* ]] && [[ -f "$tmpdir/terragrunt" ]] } test_install_no_verification_at_all() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # Install with no checksum and no signature verification local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify --no-verify-sig 2>&1) [[ "$output" == *"Skipping checksum verification"* ]] && [[ "$output" != *"SHA256 checksum verified"* ]] && [[ "$output" != *"Signature verified"* ]] && [[ -f "$tmpdir/terragrunt" ]] && [[ -x "$tmpdir/terragrunt" ]] } test_checksum_verification() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig 2>&1) [[ "$output" == *"SHA256 checksum verified"* ]] } test_old_version_skips_signature() { # Requires gpg (GPG signature verification is default) command -v gpg &>/dev/null || { echo "gpg required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # v0.72.5 is below MIN_SIGNED_VERSION (0.98.0), should skip signature gracefully local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 2>&1) [[ "$output" == *"Skipping signature verification: not available for versions older than"* ]] } test_signature_enabled_by_default() { # Requires gpg (GPG signature verification is default) command -v gpg &>/dev/null || { echo "gpg required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # GPG signature verification is enabled by default # Use RC version which has signatures local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.98.0-rc2026011601 2>&1) [[ "$output" == *"Verifying GPG signature"* ]] && [[ "$output" == *"Signature verified"* ]] } test_no_verify_sig_skips_signature() { local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # With --no-verify-sig, signature verification should be skipped local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.72.5 --no-verify-sig 2>&1) [[ "$output" != *"Signature verified"* ]] && [[ "$output" != *"Using GPG"* ]] && [[ "$output" != *"Using Cosign"* ]] } test_cosign_signature_verification() { # Requires cosign command -v cosign &>/dev/null || { echo "cosign required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # Use RC version which has signatures local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.98.0-rc2026011601 --verify-cosign 2>&1) [[ "$output" == *"Verifying Cosign signature"* ]] && [[ "$output" == *"Signature verified"* ]] } test_gpg_is_default_signature_method() { # Requires gpg command -v gpg &>/dev/null || { echo "gpg required"; return 1; } local tmpdir tmpdir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand tmpdir now, not at trap time trap "rm -rf '$tmpdir'" RETURN # GPG is default method - verify it's used without any flags local output output=$(bash "$INSTALL_SCRIPT" -d "$tmpdir" -v v0.98.0-rc2026011601 2>&1) [[ "$output" == *"Verifying GPG signature"* ]] && [[ "$output" == *"Signature verified"* ]] } # --- Platform-Specific Tests --- test_macos_shasum_fallback() { # This test verifies the shasum fallback logic works # On Linux with sha256sum, we simulate by checking the code path exists if command -v sha256sum &>/dev/null; then # On Linux, verify sha256sum is used return 0 elif command -v shasum &>/dev/null; then # On macOS, verify shasum works echo "test" | shasum -a 256 >/dev/null 2>&1 else return 1 fi } test_temp_directory_cleanup() { local install_dir install_dir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional: expand install_dir now, not at trap time trap "rm -rf '$install_dir'" RETURN # Count terragrunt-specific temp dirs before local before before=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'terragrunt-install.*' -type d 2>/dev/null | wc -l) bash "$INSTALL_SCRIPT" -d "$install_dir" -v v0.72.5 --no-verify-sig >/dev/null 2>&1 # Verify no new terragrunt-specific temp dirs remain (script uses trap to cleanup) local after after=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'terragrunt-install.*' -type d 2>/dev/null | wc -l) [[ "$after" -le "$before" ]] } # --- Main --- main() { local quick_mode=false if [[ "${1:-}" == "--quick" ]]; then quick_mode=true fi echo "==========================================" echo "Terragrunt Install Script Tests" echo "==========================================" echo "" echo "--- Basic Tests ---" run_test "Script exists" test_script_exists run_test "Script has valid syntax" test_script_executable_syntax run_test "Help output contains expected content" test_help_output run_test "Help exits with code 0" test_help_exit_code run_test "Invalid option fails" test_invalid_option_fails run_test "Missing -v argument fails" test_missing_version_arg_fails run_test "Missing -d argument fails" test_missing_dir_arg_fails echo "" echo "--- Environment Tests ---" run_test "OS is supported ($(uname -s))" test_os_detection run_test "Architecture is supported ($(uname -m))" test_arch_detection run_test "Checksum tool exists (sha256sum/shasum)" test_checksum_tool_exists run_test "curl is installed" test_curl_exists run_test "Platform checksum tool works" test_macos_shasum_fallback echo "" # Check network connectivity for integration tests local skip_reason="" if [[ "$quick_mode" == true ]]; then skip_reason="quick mode" elif ! check_network_connectivity; then skip_reason="no network connectivity" fi if [[ -n "$skip_reason" ]]; then echo "--- Integration Tests (SKIPPED - ${skip_reason}) ---" skip_test "Fetch latest version from GitHub" skip_test "Install specific version" skip_test "Install RC version" skip_test "Install latest version" skip_test "Install fails when already exists" skip_test "Install with --force overwrites" skip_test "Install creates directory" skip_test "Install with invalid version fails" skip_test "Install with --no-verify skips checksum" skip_test "Install with no verification at all" skip_test "Checksum verification works" skip_test "Old version skips signature verification" skip_test "Signature enabled by default" skip_test "--no-verify-sig skips signature" skip_test "Cosign signature verification" skip_test "GPG is default signature method" skip_test "Temp directory cleanup" else echo "--- Integration Tests (require network) ---" run_test "Fetch latest version from GitHub" test_fetch_latest_version run_test "Install specific version (v0.72.5)" test_install_specific_version run_test "Install RC version (v0.98.0-rc2026011601)" test_install_rc_version run_test "Install latest version" test_install_latest_version run_test "Install fails when already exists" test_install_already_exists_fails run_test "Install with --force overwrites" test_install_force_overwrites run_test "Install creates directory" test_install_creates_directory run_test "Install with invalid version fails" test_install_invalid_version_fails run_test "Install with --no-verify skips checksum" test_install_no_verify run_test "Install with no verification at all" test_install_no_verification_at_all run_test "Checksum verification works" test_checksum_verification run_test "Old version skips signature verification" test_old_version_skips_signature run_test "Signature enabled by default" test_signature_enabled_by_default run_test "--no-verify-sig skips signature" test_no_verify_sig_skips_signature run_test "Cosign signature verification" test_cosign_signature_verification run_test "GPG is default signature method" test_gpg_is_default_signature_method run_test "Temp directory cleanup" test_temp_directory_cleanup fi echo "" echo "==========================================" echo "Results: ${TESTS_PASSED}/${TESTS_RUN} passed" if [[ $TESTS_FAILED -gt 0 ]]; then echo "${RED}${TESTS_FAILED} test(s) failed${NC}" exit 1 else echo "${GREEN}All tests passed!${NC}" exit 0 fi } main "$@" ================================================ FILE: docs/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "include": [ ".astro/types.d.ts", "**/*" ], "exclude": [ "dist" ], "compilerOptions": { "baseUrl": ".", "paths": { "@assets/*": [ "src/assets/*" ], "@components/*": [ "src/components/*" ], "@layouts/*": [ "src/layouts/*" ], "@lib/*": [ "src/lib/*" ], "@styles/*": [ "src/styles/*" ], "@ui/*": [ "src/components/ui/*" ] } } } ================================================ FILE: docs/vercel.json ================================================ { "buildCommand": "bun run build", "installCommand": "bun install", "framework": "astro", "rewrites": [ { "source": "/api/v1/compatibility", "has": [{ "type": "query", "key": "tool", "value": "opentofu" }], "destination": "/api/v1/compatibility/opentofu" }, { "source": "/api/v1/compatibility", "has": [{ "type": "query", "key": "tool", "value": "terraform" }], "destination": "/api/v1/compatibility/terraform" }, { "source": "/api/v1/compatibility", "destination": "/api/v1/compatibility/index" } ], "redirects": [ { "source": "/lp/:slug*", "destination": "https://terragrunt.com/lp/:slug*", "permanent": true }, { "source": "/terragrunt-ambassador/:slug*", "destination": "https://terragrunt.com/terragrunt-ambassador/:slug*", "permanent": true }, { "source": "/terragrunt-scale/:slug*", "destination": "https://terragrunt.com/terragrunt-scale/:slug*", "permanent": true }, { "source": "/contact-tgs/:slug*", "destination": "https://terragrunt.com/contact-tgs/:slug*", "permanent": true } ], "headers": [ { "source": "/pagefind/(.*)", "headers": [ { "key": "Access-Control-Allow-Origin", "value": "https://terragrunt.com" }, { "key": "Access-Control-Allow-Methods", "value": "GET, OPTIONS" }, { "key": "Access-Control-Allow-Headers", "value": "Content-Type" } ] } ] } ================================================ FILE: go.mod ================================================ module github.com/gruntwork-io/terragrunt go 1.26 require ( cloud.google.com/go/storage v1.61.3 dario.cat/mergo v1.0.2 github.com/NYTimes/gziphandler v1.1.1 github.com/ProtonMail/go-crypto v1.4.1 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/creack/pty v1.1.24 github.com/fatih/structs v1.1.0 github.com/getsops/sops/v3 v3.12.2 github.com/gitsight/go-vcsurl v1.0.1 github.com/go-errors/errors v1.5.1 github.com/gofrs/flock v0.13.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gruntwork-io/boilerplate v0.10.1 github.com/gruntwork-io/go-commons v0.17.2 github.com/gruntwork-io/terragrunt-engine-go v0.1.0 github.com/gruntwork-io/terratest v0.51.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-getter v1.8.5 github.com/hashicorp/go-getter/v2 v2.2.3 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.7.0 github.com/hashicorp/go-safetemp v1.0.0 github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/hcl/v2 v2.24.0 // Many functions of terraform was converted to internal to avoid use as a library after v0.15.3. This means that we // can't use terraform as a library after v0.15.3, so we pull that in here. github.com/hashicorp/terraform v0.15.3 github.com/hashicorp/terraform-svchost v0.2.0 github.com/huandu/go-clone v1.7.3 github.com/labstack/echo/v4 v4.15.1 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-zglob v0.0.6 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 github.com/posener/complete v1.2.3 github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 github.com/zclconf/go-cty v1.18.0 go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 go.opentelemetry.io/otel/metric v1.42.0 go.opentelemetry.io/otel/sdk v1.42.0 go.opentelemetry.io/otel/sdk/metric v1.42.0 go.opentelemetry.io/otel/trace v1.42.0 golang.org/x/mod v0.34.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 golang.org/x/text v0.35.0 google.golang.org/api v0.272.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.1 ) require ( github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2 github.com/aws/aws-sdk-go-v2/service/iam v1.53.6 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/aws/smithy-go v1.24.2 github.com/charmbracelet/x/exp/teatest v0.0.0-20250611152503-f53cdd7e01ef github.com/charmbracelet/x/term v0.2.1 github.com/docker/go-connections v0.6.0 github.com/go-git/go-billy/v6 v6.0.0-20260226131633-45bd0956d66f github.com/go-git/go-git/v6 v6.0.0-20260209124828-a06215dae685 github.com/gobwas/glob v0.2.3 github.com/invopop/jsonschema v0.13.0 github.com/mattn/go-shellwords v1.0.12 github.com/spf13/afero v1.15.0 github.com/testcontainers/testcontainers-go v0.41.0 github.com/wI2L/jsondiff v0.7.0 github.com/xeipuuv/gojsonschema v1.2.0 go.uber.org/mock v0.6.0 golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa ) require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/kms v1.26.0 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect filippo.io/age v1.3.1 // indirect filippo.io/edwards25519 v1.2.0 // indirect filippo.io/hpke v0.4.0 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/alecthomas/chroma/v2 v2.15.0 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/apparentlymart/go-versions v1.0.3 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ozzo/ozzo-validation v3.6.0+incompatible // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-jsonnet v0.21.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/gookit/color v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.71 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/vault/api v1.22.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.189 // indirect github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lib/pq v1.12.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/panicwrap v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/oklog/run v1.2.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pterm/pterm v0.12.82 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/urfave/cli v1.22.17 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) replace ( // atomicgo.dev started to return 404 atomicgo.dev/cursor => github.com/atomicgo/cursor v0.2.0 atomicgo.dev/keyboard => github.com/atomicgo/keyboard v0.2.9 atomicgo.dev/schedule => github.com/atomicgo/schedule v0.1.0 // Many functions of terraform was converted to internal to avoid use as a library after v0.15.3. This means that we // can't use terraform as a library after v0.15.3, so we pull that in here. github.com/hashicorp/terraform => github.com/hashicorp/terraform v0.15.3 // This is necessary to workaround go modules error with terraform importing vault incorrectly. // See https://github.com/hashicorp/vault/issues/7848 for more info github.com/hashicorp/vault => github.com/hashicorp/vault v1.4.2 // Fix for missing tencentcloud v3.0.82 tag github.com/tencentcloud/tencentcloud-sdk-go v3.0.82+incompatible => github.com/tencentcloud/tencentcloud-sdk-go v0.0.0-20190816164403-f8fa457a3c72 // TFlint introduced a BUSL license in v0.51.0, so we have to be careful not to update past this version. github.com/terraform-linters/tflint => github.com/terraform-linters/tflint v0.50.3 ) ================================================ FILE: go.sum ================================================ atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go v45.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v47.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v51.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v52.5.0+incompatible h1:/NLBWHCnIHtZyLPc1P7WIqi4Te4CC23kIQyK3Ep/7lA= github.com/Azure/azure-sdk-for-go v52.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.11.3/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= github.com/Azure/go-autorest/autorest v0.11.10/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= github.com/Azure/go-autorest/autorest/azure/cli v0.4.0/go.mod h1:JljT387FplPzBA31vUcvsetLKF3pec5bdAxjVU4kI2s= github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= github.com/Azure/go-autorest/autorest/validation v0.3.0/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/Azure/go-ntlmssp v0.0.0-20180810175552-4a21cbd618b4/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ChrisTrenkamp/goxpath v0.0.0-20170922090931-c385f95c6022/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/ChrisTrenkamp/goxpath v0.0.0-20190607011252-c5096ec8773d/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc= github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a/go.mod h1:T9M45xf79ahXVelWoOBmH0y4aC1t5kXO5BxwyakgIGA= github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190103054945-8205d1f41e70/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aliyun/aliyun-tablestore-go-sdk v4.1.2+incompatible/go.mod h1:LDQHRZylxvcg8H7wBIDfvO5g/cy4/sz1iucBlc2l3Jw= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antchfx/xpath v0.0.0-20190129040759-c8489ed3251e/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= github.com/antchfx/xquery v0.0.0-20180515051857-ad5b8c7a47b0/go.mod h1:LzD22aAzDP8/dyiCKFp31He4m2GPjl0AFyzDtZzUu9M= github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 h1:MzVXffFUye+ZcSR6opIgz9Co7WcDx6ZcY+RjfFHoA0I= github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/apparentlymart/go-shquot v0.0.1/go.mod h1:lw58XsE5IgUXZ9h0cxnypdx31p9mPFIVEQ9P3c7MlrU= github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13/go.mod h1:7kfpUbyCdGJ9fDRCp3fopPQi5+cKNHgTE4ZuNrO71Cw= github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM= github.com/apparentlymart/go-versions v1.0.3 h1:T3b8tumoQLuu1dej2Y9v22J4PWV9IzDLh2A9lIPoVSM= github.com/apparentlymart/go-versions v1.0.3/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM= github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atomicgo/cursor v0.2.0 h1:nRFeKNcH6uUISSCc1F68IIVUBqXFOkBzL9qHxZ/5DX0= github.com/atomicgo/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= github.com/atomicgo/keyboard v0.2.9 h1:3lNinZmrQnFjzI19CiNeKg9w+UCaUa625MtLs8iLOxE= github.com/atomicgo/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= github.com/atomicgo/schedule v0.1.0 h1:0doeK5hjAExAfB4yYXVnSUdJh18+qjmP3tE7uWx4N5E= github.com/atomicgo/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2/go.mod h1:bo7DhmS/OyVeAJTC768nEk92YKWskqJ4gn0gB5e59qQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2 h1:xi/ECwajy2mixviBD7bKAlGGSwzEaFKX2wIhrZt9NGw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2/go.mod h1:dLREOeW66eVaaGIOi2ZlLHDgkR3nuJ02rd00j0YSlBE= github.com/aws/aws-sdk-go-v2/service/iam v1.53.6 h1:GPQvvxy8+FDnD9xKYzGKJMjIm5xkVM5pd3bFgRldNSo= github.com/aws/aws-sdk-go-v2/service/iam v1.53.6/go.mod h1:RJNVc52A0K41fCDJOnsCLeWJf8mwa0q30fM3CfE9U18= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 h1:ru+seMuylHiNZlvgZei83eD8h37hRjm1XIMOEmcV0BU= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20/go.mod h1:ihZMtPTKoX/ugQRHbui6zNdSgVYN1KY2Dgwb2d3hXlc= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 h1:s/zDSG/a/Su9aX+v0Ld9cimUCdkr5FWPmBV8owaEbZY= github.com/aws/aws-sdk-go-v2/service/kms v1.50.3/go.mod h1:/iSgiUor15ZuxFGQSTf3lA2FmKxFsQoc2tADOarQBSw= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20250611152503-f53cdd7e01ef h1:Wcfy1WTykT4c55mCN1a+HiuHXgkv3i9a5Jdo9E+rM1s= github.com/charmbracelet/x/exp/teatest v0.0.0-20250611152503-f53cdd7e01ef/go.mod h1:MhV4atqUTcHvdaA7Qbkgb0Tvvr+BrH6IW7/i2XW39R8= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/bbolt v1.3.0/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/dylanmei/iso8601 v0.1.0/go.mod h1:w9KhXSgIyROl1DefbMYIE7UVSIvELTbMrCfx+QkYnoQ= github.com/dylanmei/winrmtest v0.0.0-20190225150635-99b7fe2fddf1/go.mod h1:lcy9/2gH1jn/VCLouHA6tOEwLoNVd4GW6zhuKLmHC2Y= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v0.0.0-20190203023257-5858425f7550/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e h1:y/1nzrdF+RPds4lfoEpNhjfmzlgZtPqyO3jMzrqDQws= github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM= github.com/getsops/sops/v3 v3.12.2 h1:4ctEFDNpAAubW8EMICytX8+BFDBSFJkrKvQ9ahSs0a4= github.com/getsops/sops/v3 v3.12.2/go.mod h1:BACmHQl0J8nPNXBDSJKRT5oUdZx36CkbohGDj9+bD9M= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gitsight/go-vcsurl v1.0.1 h1:wkijKsbVg9R2IBP97U7wOANeIW9WJJKkBwS9XqllzWo= github.com/gitsight/go-vcsurl v1.0.1/go.mod h1:qRFdKDa/0Lh9MT0xE+qQBYZ/01+mY1H40rZUHR24X9U= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-20260226131633-45bd0956d66f h1:Uvbx7nITO3Sd1GdXarX0TbyYmOaSNIJP0mm4LocEyyA= github.com/go-git/go-billy/v6 v6.0.0-20260226131633-45bd0956d66f/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw= github.com/go-git/go-git/v6 v6.0.0-20260209124828-a06215dae685 h1:qtAWtG/GhxLMK5J9Nh4WrhwqXq8C1rRLMD45S1iUyNs= github.com/go-git/go-git/v6 v6.0.0-20260209124828-a06215dae685/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v0.0.0-20171007142547-342cbe0a0415/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-jsonnet v0.21.0 h1:43Bk3K4zMRP/aAZm9Po2uSEjY6ALCkYUVIcz9HLGMvA= github.com/google/go-jsonnet v0.21.0/go.mod h1:tCGAu8cpUpEZcdGMmdOu37nh8bGgqubhI5v2iSk3KJQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/gophercloud/gophercloud v0.6.1-0.20191122030953-d8ac278c1c9d/go.mod h1:ozGNgr9KYOVATV5jsgHl/ceCDXGuguqOZAzoQ/2vcNM= github.com/gophercloud/gophercloud v0.10.1-0.20200424014253-c3bfe50899e5/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss= github.com/gophercloud/utils v0.0.0-20200423144003-7c72efc7435d/go.mod h1:ehWUbLQJPqS0Ep+CxeD559hsm9pthPXadJNKwZkp43w= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce9cOegemcy/9Ai5k3huT6E80F3zaw= github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/gruntwork-io/boilerplate v0.10.1 h1:f+NCks9hMNkaPa1bVVnnNWeN9UoEG7ATS68nyTuPU/A= github.com/gruntwork-io/boilerplate v0.10.1/go.mod h1:l6lJfbixOrrSurXQ2U98hFCKC04xSYQo6kCnfbgIbec= github.com/gruntwork-io/go-commons v0.17.2 h1:14dsCJ7M5Vv2X3BIPKeG9Kdy6vTMGhM8L4WZazxfTuY= github.com/gruntwork-io/go-commons v0.17.2/go.mod h1:zs7Q2AbUKuTarBPy19CIxJVUX/rBamfW8IwuWKniWkE= github.com/gruntwork-io/terragrunt-engine-go v0.1.0 h1:N/8q099DtaKUgzVGMW2oOYhV8kc5x3Wd/PKlh7G8TRA= github.com/gruntwork-io/terragrunt-engine-go v0.1.0/go.mod h1:bkjFHAYc8NBV8SkTQyHRhqBebbIsZyFv8mX5r/PAjcM= github.com/gruntwork-io/terratest v0.51.0 h1:RCXlCwWlHqhUoxgF6n3hvywvbvrsTXqoqt34BrnLekw= github.com/gruntwork-io/terratest v0.51.0/go.mod h1:evZHXb8VWDgv5O5zEEwfkwMhkx9I53QR/RB11cISrpg= github.com/hashicorp/aws-sdk-go-base v0.6.0/go.mod h1:2fRjWDv3jJBeN6mVWFHV6hFTNeFBx2gpDLQaZNxUVAY= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.71 h1:3qrWTgbR0uMacRVnE6//G1B20hUJexxqqmQ2OTs1+0s= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.71/go.mod h1:YV27+mh2SLUqeP36G1a9MiqL5eBkFnZQJjNTR9Q9NcY= github.com/hashicorp/consul v0.0.0-20171026175957-610f3c86a089/go.mod h1:mFrjN1mfidgJfYP1xrJCF+AfRhr6Eaqhb2+sfyn/OOI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-azure-helpers v0.12.0/go.mod h1:Zc3v4DNeX6PDdy7NljlYpnrdac1++qNW0I4U+ofGwpg= github.com/hashicorp/go-azure-helpers v0.14.0/go.mod h1:kR7+sTDEb9TOp/O80ss1UEJg1t4/BHLD/U8wHLS4BGQ= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.5.1/go.mod h1:a7z7NPPfNQpJWcn4rSWFtdrSldqLdLPEF3d8nFMsSLM= github.com/hashicorp/go-getter v1.8.5 h1:DMPV5CSw5JrNg/IK7kDZt3+l2REKXOi3oAw7uYLh2NM= github.com/hashicorp/go-getter v1.8.5/go.mod h1:WIffejwAyDSJhoVptc3UEshEMkR9O63rw34V7k43O3Q= github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.15.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v0.0.0-20180129170900-7f3cd4390caa/go.mod h1:6ij3Z20p+OhOkCSrA0gImAWoHYQRGbnlcuk6XYTiaRw= github.com/hashicorp/go-msgpack v0.5.4/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.1/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-tfe v0.14.0/go.mod h1:B71izbwmCZdhEo/GzHopCXN3P74cYv2tsff1mxY4J6c= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= github.com/hashicorp/hcl/v2 v2.10.0/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/jsonapi v0.0.0-20210420151930-edf82c9774bf/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= github.com/hashicorp/memberlist v0.1.0/go.mod h1:ncdBp14cuox2iFOq3kDiquKU6fqsTBc3W6JvZwjxxsE= github.com/hashicorp/serf v0.0.0-20160124182025-e4ec8cc423bb/go.mod h1:h/Ru6tmZazX7WO/GDmwdpS975F019L4t5ng5IgwbNrE= github.com/hashicorp/terraform v0.15.3 h1:2QWbTj2xJ/8W1gCyIrd0WAqVF4weKPMYjx8nKjbkQjA= github.com/hashicorp/terraform v0.15.3/go.mod h1:w4eBEsluZfYumXUTLe834eqHh969AabcLqbj2WAYlM8= github.com/hashicorp/terraform-config-inspect v0.0.0-20210209133302-4fd17a0faac2/go.mod h1:Z0Nnk4+3Cy89smEbrq+sl1bxc9198gIP4I7wcQF6Kqs= github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg= github.com/hashicorp/terraform-svchost v0.2.0 h1:wVc2vMiodOHvNZcQw/3y9af1XSomgjGSv+rv3BMCk7I= github.com/hashicorp/terraform-svchost v0.2.0/go.mod h1:/98rrS2yZsbppi4VGVCjwYmh8dqsKzISqK7Hli+0rcQ= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.189 h1:YQ+Lx4u4FdStckox6Ecnc/4i1knjXm1r3KhNRmbvqG4= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.189/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 h1:fO9A67/izFYFYky7l1pDP5Dr0BTCRkaQJUG6Jm5ehsk= github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3/go.mod h1:Ey4uAp+LvIl+s5jRbOHLcZpUDnkjLBROl15fZLwPlTM= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joyent/triton-go v0.0.0-20180313100802-d8f9c0314926/go.mod h1:U+RSyWxWd04xTqnuOQxnai7XGS2PrPY2cfGoDKtMHjA= github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v0.0.0-20180701071628-ab8a2e0c74be/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/likexian/gokit v0.0.0-20190309162924-0a377eecf7aa/go.mod h1:QdfYv6y6qPA9pbBA2qXtoT8BMKha6UyNbxWGWl/9Jfk= github.com/likexian/gokit v0.0.0-20190418170008-ace88ad0983b/go.mod h1:KKqSnk/VVSW8kEyO2vVCXoanzEutKdlBAPohmGXkxCk= github.com/likexian/gokit v0.0.0-20190501133040-e77ea8b19cdc/go.mod h1:3kvONayqCaj+UgrRZGpgfXzHdMYCAO0KAt4/8n0L57Y= github.com/likexian/gokit v0.20.15/go.mod h1:kn+nTv3tqh6yhor9BC4Lfiu58SmH8NmQ2PmEl+uM6nU= github.com/likexian/simplejson-go v0.0.0-20190409170913-40473a74d76d/go.mod h1:Typ1BfnATYtZ/+/shXfFYLrovhFyuKvzwrdOnIDHlmg= github.com/likexian/simplejson-go v0.0.0-20190419151922-c1f9f0b4f084/go.mod h1:U4O1vIJvIKwbMZKUJ62lppfdvkCdVd2nfMimHK81eec= github.com/likexian/simplejson-go v0.0.0-20190502021454-d8787b4bfa0b/go.mod h1:3BWwtmKP9cXWwYCr5bkoVDEfLywacOv0s06OBEDpyt8= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lusis/go-artifactory v0.0.0-20160115162124-7e4ce345df82/go.mod h1:y54tfGmO3NKssKveTEFFzH8C/akrSOy/iW9qEAUDV84= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= github.com/masterzen/winrm v0.0.0-20200615185753-c42b5136ff88/go.mod h1:a2HXwefeat3evJHxFXSayvRHpYEPJYtErl4uIzfaUqY= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.4/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.0.8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb/go.mod h1:OaY7UOoTkkrX3wRwjpYRKafIkkyeD0UtweSHAWWiqQM= github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/gox v1.0.1/go.mod h1:ED6BioOGXMswlXa2zxfh/xdd5QhwYliBFn9V18Ap4z4= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/panicwrap v1.0.0 h1:67zIyVakCIvcs69A0FGfZjBdPleaonSgGlXRSRlb6fE= github.com/mitchellh/panicwrap v1.0.0/go.mod h1:pKvZHwWrZowLUzftuFq7coarnxbBXU4aQh3N0BJOeeA= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v0.0.0-20190113212917-5533ce8a0da3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runc v1.2.8 h1:RnEICeDReapbZ5lZEgHvj7E9Q3Eex9toYmaGBsbvU5Q= github.com/opencontainers/runc v1.2.8/go.mod h1:cC0YkmZcuvr+rtBZ6T7NBoVbMGNAdLa/21vIElJDOzI= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/packer-community/winrmcp v0.0.0-20180921211025-c76d91c1e7db/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 h1:S1hI5JiKP7883xBzZAr1ydcxrKNSVNm7+3+JwjxZEsg= github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25/go.mod h1:ZQntvDG8TkPgljxtA0R9frDoND4QORU1VXz015N5Ks4= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ= github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stuart-warren/yamlfmt v0.1.2 h1:ojguhYdHpNWy62fLkrQtLFGAzrqFxVaU8f5Z0U8mkMI= github.com/stuart-warren/yamlfmt v0.1.2/go.mod h1:X5TuPH+hf4O0U1KBvNqygvHbvAnoi9Wyl9BbtPv8SZk= github.com/tencentcloud/tencentcloud-sdk-go v0.0.0-20190816164403-f8fa457a3c72/go.mod h1:0PfYow01SHPMhKY31xa+EFz2RStxIqj6JFAJS+IkCi4= github.com/tencentyun/cos-go-sdk-v5 v0.0.0-20190808065407-f07404cefc8c/go.mod h1:wk2XFUg6egk4tSDNZtXeKfe2G6690UVyt163PuUxBZk= github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tmc/grpc-websocket-proxy v0.0.0-20171017195756-830351dc03c6/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tombuildsstuff/giovanni v0.15.1/go.mod h1:0TZugJPEtqzPlMpuJHYfXY6Dq2uLPrXf98D2XQSxNbA= github.com/ugorji/go v0.0.0-20180813092308-00b869d2f4a5/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.8.3/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.0.2/go.mod h1:IP3Ylp0wQpYm50IHK8OZWKMu6sPJIUgKa8XhiVHura0= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191203134012-c197fd4bf371/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.34.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.0.0-20190620084959-7cf5895f2711/go.mod h1:TBhBqb1AWbBQbW3XRusr7n7E4v2+5ZY8r8sAMnyFC5A= k8s.io/apimachinery v0.0.0-20190612205821-1799e75a0719/go.mod h1:I4A+glKBHiTgiEjQiCCQfCAIcIMFGt291SmsvcrFzJA= k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655/go.mod h1:nL6pwRT8NgfF8TT68DBI8uEePRt89cSvoXUVqbkWHq4= k8s.io/client-go v10.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/kube-openapi v0.0.0-20190228160746-b3a7cee44a30/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= k8s.io/utils v0.0.0-20200411171748-3d5a2fe318e4/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: internal/awshelper/config.go ================================================ // Package awshelper provides helper functions for working with AWS services. package awshelper import ( "context" "os" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" awsiam "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/gruntwork-io/go-commons/version" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( // Minimum ARN parts required for a valid ARN minARNParts = 2 ) // AwsSessionConfig is a representation of the configuration options for an AWS Config type AwsSessionConfig struct { Tags map[string]string Region string CustomS3Endpoint string CustomDynamoDBEndpoint string Profile string RoleArn string CredsFilename string ExternalID string SessionName string S3ForcePathStyle bool DisableComputeChecksums bool } type tokenFetcher string // FetchToken implements the token fetcher interface. // Supports providing a token value or the path to a token on disk func (f tokenFetcher) FetchToken(_ context.Context) ([]byte, error) { // Check if token is a raw value if _, err := os.Stat(string(f)); err != nil { // TODO: See if this lint error should be ignored return []byte(f), nil //nolint: nilerr } token, err := os.ReadFile(string(f)) if err != nil { return nil, errors.New(err) } return token, nil } // AWSConfigBuilder builds an AWS config using the builder pattern. // Use NewAwsConfigBuilder to create, chain With* methods for optional parameters, then call Build(). type AWSConfigBuilder struct { sessionConfig *AwsSessionConfig env map[string]string iamRoleOpts iam.RoleOptions } // NewAWSConfigBuilder creates a new builder for AWS config. func NewAWSConfigBuilder() *AWSConfigBuilder { return &AWSConfigBuilder{ env: make(map[string]string), } } // WithSessionConfig sets the AWS session configuration (region, profile, credentials file, etc.). func (b *AWSConfigBuilder) WithSessionConfig(cfg *AwsSessionConfig) *AWSConfigBuilder { b.sessionConfig = cfg return b } // WithEnv sets environment variables used for credential and region resolution. func (b *AWSConfigBuilder) WithEnv(env map[string]string) *AWSConfigBuilder { b.env = env return b } // WithIAMRoleOptions sets IAM role options for assuming a role. func (b *AWSConfigBuilder) WithIAMRoleOptions(opts iam.RoleOptions) *AWSConfigBuilder { b.iamRoleOpts = opts return b } // Build creates the AWS config from the builder's configuration. func (b *AWSConfigBuilder) Build(ctx context.Context, l log.Logger) (aws.Config, error) { var configOptions []func(*config.LoadOptions) error configOptions = append(configOptions, config.WithAppID("terragrunt/"+version.GetVersion())) if envCreds := createCredentialsFromEnv(b.env); envCreds != nil { l.Debugf("Using AWS credentials from auth provider command") configOptions = append(configOptions, config.WithCredentialsProvider(envCreds)) } else if b.sessionConfig != nil && b.sessionConfig.CredsFilename != "" { configOptions = append(configOptions, config.WithSharedConfigFiles([]string{b.sessionConfig.CredsFilename})) } // Prioritize configured region over environment variables // This fixes the issue where AWS_REGION/AWS_DEFAULT_REGION env vars override the backend config region var region string if b.sessionConfig != nil && b.sessionConfig.Region != "" { region = b.sessionConfig.Region } else { region = getRegionFromEnv(b.env) } if region == "" { region = "us-east-1" } configOptions = append(configOptions, config.WithRegion(region)) if b.sessionConfig != nil && b.sessionConfig.Profile != "" { configOptions = append(configOptions, config.WithSharedConfigProfile(b.sessionConfig.Profile)) } cfg, err := config.LoadDefaultConfig(ctx, configOptions...) if err != nil { return aws.Config{}, errors.Errorf("Error loading AWS config: %w", err) } if createCredentialsFromEnv(b.env) != nil { return cfg, nil } mergedIAMRoleOptions := getMergedIAMRoleOptions(b.sessionConfig, b.iamRoleOpts) if mergedIAMRoleOptions.RoleARN == "" { return cfg, nil } if mergedIAMRoleOptions.WebIdentityToken != "" { l.Debugf("Assuming role %s using WebIdentity token", mergedIAMRoleOptions.RoleARN) cfg.Credentials = getWebIdentityCredentialsFromIAMRoleOptions(cfg, mergedIAMRoleOptions) return cfg, nil } l.Debugf("Assuming role %s", mergedIAMRoleOptions.RoleARN) cfg.Credentials = getSTSCredentialsFromIAMRoleOptions(cfg, mergedIAMRoleOptions, getExternalID(b.sessionConfig)) return cfg, nil } // BuildS3Client creates an S3 client from the builder's configuration. // The session config (set via WithSessionConfig) provides S3-specific options like custom endpoint and path style. func (b *AWSConfigBuilder) BuildS3Client(ctx context.Context, l log.Logger) (*s3.Client, error) { cfg, err := b.Build(ctx, l) if err != nil { return nil, errors.New(err) } if b.sessionConfig == nil { return s3.NewFromConfig(cfg), nil } customFN := make([]func(*s3.Options), 0, 2) //nolint:mnd if b.sessionConfig.CustomS3Endpoint != "" { customFN = append(customFN, func(o *s3.Options) { o.BaseEndpoint = aws.String(b.sessionConfig.CustomS3Endpoint) }) } if b.sessionConfig.S3ForcePathStyle { customFN = append(customFN, func(o *s3.Options) { o.UsePathStyle = true }) } return s3.NewFromConfig(cfg, customFN...), nil } // getRegionFromEnv extracts region from environment variables. func getRegionFromEnv(env map[string]string) string { if len(env) == 0 { return "" } if region := env["AWS_REGION"]; region != "" { return region } return env["AWS_DEFAULT_REGION"] } // getMergedIAMRoleOptions merges IAM role options from awsCfg and the provided IAM role options. func getMergedIAMRoleOptions(awsCfg *AwsSessionConfig, iamRoleOpts iam.RoleOptions) iam.RoleOptions { // Merge in awsCfg role options if available if awsCfg != nil && awsCfg.RoleArn != "" { iamRoleOpts = iam.MergeRoleOptions( iamRoleOpts, iam.RoleOptions{ RoleARN: awsCfg.RoleArn, AssumeRoleSessionName: awsCfg.SessionName, }, ) } return iamRoleOpts } // getExternalID returns the external ID from awsCfg if available func getExternalID(awsCfg *AwsSessionConfig) string { if awsCfg == nil { return "" } return awsCfg.ExternalID } // AssumeIamRole assumes an IAM role and returns the credentials. func AssumeIamRole( ctx context.Context, iamRoleOpts iam.RoleOptions, externalID string, env map[string]string, ) (*types.Credentials, error) { region := getRegionFromEnv(env) if region == "" { region = os.Getenv("AWS_REGION") } if region == "" { region = os.Getenv("AWS_DEFAULT_REGION") } if region == "" { region = "us-east-1" } // Set user agent to include terragrunt version cfg, err := config.LoadDefaultConfig( ctx, config.WithRegion(region), config.WithAppID("terragrunt/"+version.GetVersion()), ) if err != nil { return nil, errors.Errorf("Error loading AWS config: %w", err) } stsClient := sts.NewFromConfig(cfg) roleSessionName := iamRoleOpts.AssumeRoleSessionName if roleSessionName == "" { roleSessionName = iam.GetDefaultAssumeRoleSessionName() } duration := time.Duration(iam.DefaultAssumeRoleDuration) * time.Second if iamRoleOpts.AssumeRoleDuration > 0 { duration = time.Duration(iamRoleOpts.AssumeRoleDuration) * time.Second } if iamRoleOpts.WebIdentityToken != "" { // Use sts AssumeRoleWithWebIdentity tb, err := tokenFetcher(iamRoleOpts.WebIdentityToken).FetchToken(ctx) if err != nil { return nil, errors.Errorf("Error reading web identity token file: %w", err) } input := &sts.AssumeRoleWithWebIdentityInput{ RoleArn: aws.String(iamRoleOpts.RoleARN), RoleSessionName: aws.String(roleSessionName), WebIdentityToken: aws.String(string(tb)), DurationSeconds: aws.Int32(int32(duration.Seconds())), } result, err := stsClient.AssumeRoleWithWebIdentity(ctx, input) if err != nil { return nil, errors.Errorf("Error assuming role with web identity: %w", err) } return result.Credentials, nil } // Use regular sts AssumeRole input := &sts.AssumeRoleInput{ RoleArn: aws.String(iamRoleOpts.RoleARN), RoleSessionName: aws.String(roleSessionName), DurationSeconds: aws.Int32(int32(duration.Seconds())), } if externalID != "" { input.ExternalId = aws.String(externalID) } result, err := stsClient.AssumeRole(ctx, input) if err != nil { return nil, errors.Errorf("Error assuming role: %w", err) } return result.Credentials, nil } // GetAWSCallerIdentity gets the caller identity from AWS func GetAWSCallerIdentity(ctx context.Context, cfg *aws.Config) (*sts.GetCallerIdentityOutput, error) { stsClient := sts.NewFromConfig(*cfg) return stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) } // ValidateAwsConfig validates that the AWS config has valid credentials func ValidateAwsConfig(ctx context.Context, cfg *aws.Config) error { _, err := GetAWSCallerIdentity(ctx, cfg) return err } // GetAWSPartition gets the AWS partition from the caller identity func GetAWSPartition(ctx context.Context, cfg *aws.Config) (string, error) { result, err := GetAWSCallerIdentity(ctx, cfg) if err != nil { return "", err } // Extract partition from ARN arn := aws.ToString(result.Arn) if arn == "" { return "", errors.New("Empty ARN returned from GetCallerIdentity") } // ARN format: arn:partition:service:region:account:resource parts := strings.Split(arn, ":") if len(parts) < minARNParts { return "", errors.Errorf("Invalid ARN format: %s", arn) } return parts[1], nil } // GetAWSAccountAlias gets the AWS account alias func GetAWSAccountAlias(ctx context.Context, cfg *aws.Config) (string, error) { iamClient := awsiam.NewFromConfig(*cfg) result, err := iamClient.ListAccountAliases(ctx, &awsiam.ListAccountAliasesInput{}) if err != nil { return "", err } if len(result.AccountAliases) == 0 { return "", nil } return result.AccountAliases[0], nil } // GetAWSAccountID gets the AWS account ID from the caller identity func GetAWSAccountID(ctx context.Context, cfg *aws.Config) (string, error) { result, err := GetAWSCallerIdentity(ctx, cfg) if err != nil { return "", err } return aws.ToString(result.Account), nil } // GetAWSIdentityArn gets the AWS identity ARN from the caller identity func GetAWSIdentityArn(ctx context.Context, cfg *aws.Config) (string, error) { result, err := GetAWSCallerIdentity(ctx, cfg) if err != nil { return "", err } return aws.ToString(result.Arn), nil } // GetAWSUserID gets the AWS user ID from the caller identity func GetAWSUserID(ctx context.Context, cfg *aws.Config) (string, error) { result, err := GetAWSCallerIdentity(ctx, cfg) if err != nil { return "", err } return aws.ToString(result.UserId), nil } // ValidatePublicAccessBlock validates the public access block configuration func ValidatePublicAccessBlock(output *s3.GetPublicAccessBlockOutput) (bool, error) { if output.PublicAccessBlockConfiguration == nil { return false, nil } config := output.PublicAccessBlockConfiguration return aws.ToBool(config.BlockPublicAcls) && aws.ToBool(config.BlockPublicPolicy) && aws.ToBool(config.IgnorePublicAcls) && aws.ToBool(config.RestrictPublicBuckets), nil } //nolint:gocritic // hugeParam: intentionally pass by value to avoid recursive credential resolution func getWebIdentityCredentialsFromIAMRoleOptions( cfg aws.Config, iamRoleOptions iam.RoleOptions, ) aws.CredentialsProviderFunc { roleSessionName := iamRoleOptions.AssumeRoleSessionName if roleSessionName == "" { // Set a unique session name in the same way it is done in the SDK roleSessionName = strconv.FormatInt(time.Now().UTC().UnixNano(), 10) } return func(ctx context.Context) (aws.Credentials, error) { stsClient := sts.NewFromConfig(cfg) token, err := tokenFetcher(iamRoleOptions.WebIdentityToken).FetchToken(ctx) if err != nil { return aws.Credentials{}, err } duration := time.Duration(iam.DefaultAssumeRoleDuration) * time.Second if iamRoleOptions.AssumeRoleDuration > 0 { duration = time.Duration(iamRoleOptions.AssumeRoleDuration) * time.Second } input := &sts.AssumeRoleWithWebIdentityInput{ RoleArn: aws.String(iamRoleOptions.RoleARN), RoleSessionName: aws.String(roleSessionName), WebIdentityToken: aws.String(string(token)), DurationSeconds: aws.Int32(int32(duration.Seconds())), } result, err := stsClient.AssumeRoleWithWebIdentity(ctx, input) if err != nil { return aws.Credentials{}, err } return aws.Credentials{ AccessKeyID: aws.ToString(result.Credentials.AccessKeyId), SecretAccessKey: aws.ToString(result.Credentials.SecretAccessKey), SessionToken: aws.ToString(result.Credentials.SessionToken), CanExpire: true, Expires: aws.ToTime(result.Credentials.Expiration), }, nil } } //nolint:gocritic // hugeParam: intentionally pass by value to avoid recursive credential resolution func getSTSCredentialsFromIAMRoleOptions( cfg aws.Config, iamRoleOptions iam.RoleOptions, externalID string, ) aws.CredentialsProviderFunc { return func(ctx context.Context) (aws.Credentials, error) { stsClient := sts.NewFromConfig(cfg) roleSessionName := iamRoleOptions.AssumeRoleSessionName if roleSessionName == "" { roleSessionName = strconv.FormatInt(time.Now().UTC().UnixNano(), 10) } duration := time.Duration(iam.DefaultAssumeRoleDuration) * time.Second if iamRoleOptions.AssumeRoleDuration > 0 { duration = time.Duration(iamRoleOptions.AssumeRoleDuration) * time.Second } input := &sts.AssumeRoleInput{ RoleArn: aws.String(iamRoleOptions.RoleARN), RoleSessionName: aws.String(roleSessionName), DurationSeconds: aws.Int32(int32(duration.Seconds())), } if externalID != "" { input.ExternalId = aws.String(externalID) } result, err := stsClient.AssumeRole(ctx, input) if err != nil { return aws.Credentials{}, err } return aws.Credentials{ AccessKeyID: aws.ToString(result.Credentials.AccessKeyId), SecretAccessKey: aws.ToString(result.Credentials.SecretAccessKey), SessionToken: aws.ToString(result.Credentials.SessionToken), CanExpire: true, Expires: aws.ToTime(result.Credentials.Expiration), }, nil } } // createCredentialsFromEnv creates AWS credentials from environment variables. func createCredentialsFromEnv(env map[string]string) aws.CredentialsProvider { if len(env) == 0 { return nil } accessKeyID := env["AWS_ACCESS_KEY_ID"] secretAccessKey := env["AWS_SECRET_ACCESS_KEY"] sessionToken := env["AWS_SESSION_TOKEN"] // If we don't have at least access key and secret key, return nil if accessKeyID == "" || secretAccessKey == "" { return nil } return credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, sessionToken) } ================================================ FILE: internal/awshelper/config_test.go ================================================ //go:build aws package awshelper_test import ( "context" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAwsSessionValidationFail(t *testing.T) { t.Skip("Skipping for now as we need to change the signature of CreateAwsConfig") t.Parallel() l := logger.CreateLogger() _, err := awshelper.NewAWSConfigBuilder(). WithSessionConfig(&awshelper.AwsSessionConfig{ Region: "not-existing-region", CredsFilename: "/tmp/not-existing-file", }). Build(t.Context(), l) assert.Error(t, err) } // Test to validate cases when is not possible to read all S3 configurations // https://github.com/gruntwork-io/terragrunt/issues/2109 func TestAwsNegativePublicAccessResponse(t *testing.T) { t.Parallel() testCases := []struct { response *s3.GetPublicAccessBlockOutput name string }{ { name: "nil-response", response: &s3.GetPublicAccessBlockOutput{ PublicAccessBlockConfiguration: nil, }, }, { name: "legacy-bucket", response: &s3.GetPublicAccessBlockOutput{ PublicAccessBlockConfiguration: &s3types.PublicAccessBlockConfiguration{ BlockPublicAcls: nil, BlockPublicPolicy: nil, IgnorePublicAcls: nil, RestrictPublicBuckets: nil, }, }, }, { name: "false-response", response: &s3.GetPublicAccessBlockOutput{ PublicAccessBlockConfiguration: &s3types.PublicAccessBlockConfiguration{ BlockPublicAcls: aws.Bool(false), BlockPublicPolicy: aws.Bool(false), IgnorePublicAcls: aws.Bool(false), RestrictPublicBuckets: aws.Bool(false), }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() response, err := awshelper.ValidatePublicAccessBlock(tc.response) require.NoError(t, err) assert.False(t, response) }) } } func TestCreateAwsConfigWithAuthProviderEnv(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx := context.Background() env := map[string]string{ "AWS_ACCESS_KEY_ID": "test-access-key", "AWS_SECRET_ACCESS_KEY": "test-secret-key", "AWS_SESSION_TOKEN": "test-session-token", "AWS_REGION": "us-west-2", } cfg, err := awshelper.NewAWSConfigBuilder(). WithEnv(env). Build(ctx, l) require.NoError(t, err) assert.Equal(t, "us-west-2", cfg.Region) assert.NotNil(t, cfg.Credentials) } func TestCreateAwsConfigWithAuthProviderEnvDefaultRegion(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx := context.Background() env := map[string]string{ "AWS_ACCESS_KEY_ID": "test-access-key", "AWS_SECRET_ACCESS_KEY": "test-secret-key", "AWS_DEFAULT_REGION": "eu-west-1", } cfg, err := awshelper.NewAWSConfigBuilder(). WithEnv(env). Build(ctx, l) require.NoError(t, err) assert.Equal(t, "eu-west-1", cfg.Region) assert.NotNil(t, cfg.Credentials) } func TestAwsConfigRegionTakesPrecedenceOverEnvVars(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx := context.Background() // Simulate env vars; do not mutate process env in parallel tests env := map[string]string{ "AWS_REGION": "us-west-1", "AWS_DEFAULT_REGION": "us-west-1", "AWS_ACCESS_KEY_ID": "test-access-key", "AWS_SECRET_ACCESS_KEY": "test-secret-key", } // Create config with explicit region that should take precedence awsCfg := &awshelper.AwsSessionConfig{ Region: "us-east-1", // This should override the env vars } cfg, err := awshelper.NewAWSConfigBuilder(). WithSessionConfig(awsCfg). WithEnv(env). Build(ctx, l) require.NoError(t, err) // Verify that the config uses the region from awsCfg, not from environment variables assert.Equal(t, "us-east-1", cfg.Region) } ================================================ FILE: internal/awshelper/policy.go ================================================ package awshelper import "encoding/json" // Policy - representation of the policy for AWS type Policy struct { Version string `json:"Version"` Statement []Statement `json:"Statement"` } // Statement - AWS policy statement // Action and Resource - can be string OR array of strings // https://docs.aws.amazon.com/IAM//latest/UserGuide/reference_policies_elements_action.html // https://docs.aws.amazon.com/IAM//latest/UserGuide/reference_policies_elements_resource.html type Statement struct { Principal any `json:"Principal,omitempty"` NotPrincipal any `json:"NotPrincipal,omitempty"` Action any `json:"Action"` Resource any `json:"Resource"` Condition *map[string]any `json:"Condition,omitempty"` Sid string `json:"Sid"` Effect string `json:"Effect"` } func UnmarshalPolicy(policy string) (Policy, error) { var p Policy err := json.Unmarshal([]byte(policy), &p) if err != nil { return p, err } return p, nil } func MarshalPolicy(policy Policy) ([]byte, error) { policyJSON, err := json.Marshal(policy) if err != nil { return nil, err } return policyJSON, nil } ================================================ FILE: internal/awshelper/policy_test.go ================================================ //go:build aws package awshelper_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const simplePolicy = ` { "Version": "2012-10-17", "Statement": [ { "Sid": "StringValues", "Effect": "Allow", "Action": "s3:*", "Resource": "*" } ] } ` const arraysPolicy = ` { "Version": "2012-10-17", "Statement": [ { "Sid": "Lists", "Effect": "Allow", "Action": [ "s3:ListStorageLensConfigurations", "s3:ListAccessPointsForObjectLambda", "s3:ListBucketMultipartUploads", "s3:ListAllMyBuckets", "s3:DescribeJob", "s3:ListAccessPoints", "s3:ListJobs", "s3:ListBucketVersions", "s3:ListBucket", "s3:ListMultiRegionAccessPoints", "s3:ListMultipartUploadParts" ], "Resource": [ "arn:aws:s3:::*", "arn:aws:s3:*:666:job/*" ] } ] } ` func TestAwsUnmarshalStringActionResource(t *testing.T) { t.Parallel() bucketPolicy, err := awshelper.UnmarshalPolicy(simplePolicy) require.NoError(t, err) assert.NotNil(t, bucketPolicy) assert.Len(t, bucketPolicy.Statement, 1) assert.NotNil(t, bucketPolicy.Statement[0].Action) assert.NotNil(t, bucketPolicy.Statement[0].Resource) switch action := bucketPolicy.Statement[0].Action.(type) { case string: assert.Equal(t, "s3:*", action) default: assert.Fail(t, "Expected string type for Action") } switch resource := bucketPolicy.Statement[0].Resource.(type) { case string: assert.Equal(t, "*", resource) default: assert.Fail(t, "Expected string type for Resource") } out, err := awshelper.MarshalPolicy(bucketPolicy) require.NoError(t, err) assert.NotContains(t, string(out), "null") } func TestAwsUnmarshalActionResourceList(t *testing.T) { t.Parallel() bucketPolicy, err := awshelper.UnmarshalPolicy(arraysPolicy) require.NoError(t, err) assert.NotNil(t, bucketPolicy) assert.Len(t, bucketPolicy.Statement, 1) assert.NotNil(t, bucketPolicy.Statement[0].Action) assert.NotNil(t, bucketPolicy.Statement[0].Resource) switch actions := bucketPolicy.Statement[0].Action.(type) { case []any: assert.Len(t, actions, 11) assert.Contains(t, actions, "s3:ListJobs") default: assert.Fail(t, "Expected []string type for Action") } switch resource := bucketPolicy.Statement[0].Resource.(type) { case []any: assert.Len(t, resource, 2) assert.Contains(t, resource, "arn:aws:s3:*:666:job/*") default: assert.Fail(t, "Expected []string type for Resource") } out, err := awshelper.MarshalPolicy(bucketPolicy) require.NoError(t, err) assert.NotContains(t, string(out), "null") } ================================================ FILE: internal/cache/cache.go ================================================ // Package cache provides generic cache. // It is used to store values by key and retrieve them later. package cache import ( "context" "crypto/sha256" "encoding/hex" "fmt" "sync" "time" "github.com/gruntwork-io/terragrunt/internal/telemetry" ) // Cache - generic cache implementation type Cache[V any] struct { Cache map[string]V Mutex *sync.RWMutex Name string } // NewCache - create new cache with generic type V func NewCache[V any](name string) *Cache[V] { return &Cache[V]{ Name: name, Cache: make(map[string]V), Mutex: &sync.RWMutex{}, } } // Get - fetch value from cache by key func (c *Cache[V]) Get(ctx context.Context, key string) (V, bool) { c.Mutex.RLock() defer c.Mutex.RUnlock() keyHash := sha256.Sum256([]byte(key)) cacheKey := hex.EncodeToString(keyHash[:]) value, found := c.Cache[cacheKey] telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_get", 1) if found { telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_hit", 1) } else { telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_miss", 1) } return value, found } // Put - put value into cache by key func (c *Cache[V]) Put(ctx context.Context, key string, value V) { c.Mutex.Lock() defer c.Mutex.Unlock() telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_put", 1) keyHash := sha256.Sum256([]byte(key)) cacheKey := hex.EncodeToString(keyHash[:]) c.Cache[cacheKey] = value } // ExpiringItem - item with expiration time type ExpiringItem[V any] struct { Value V Expiration time.Time } // ExpiringCache - cache with items with expiration time type ExpiringCache[V any] struct { Cache map[string]ExpiringItem[V] Mutex *sync.RWMutex Name string } // NewExpiringCache - create new cache with generic type V func NewExpiringCache[V any](name string) *ExpiringCache[V] { return &ExpiringCache[V]{ Name: name, Cache: make(map[string]ExpiringItem[V]), Mutex: &sync.RWMutex{}, } } // Get - fetch value from cache by key func (c *ExpiringCache[V]) Get(ctx context.Context, key string) (V, bool) { c.Mutex.Lock() defer c.Mutex.Unlock() item, found := c.Cache[key] telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_get", 1) if !found { telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_miss", 1) return item.Value, false } if time.Now().After(item.Expiration) { telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_expiry", 1) delete(c.Cache, key) return item.Value, false } telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_hit", 1) return item.Value, true } // Put - put value into cache by key func (c *ExpiringCache[V]) Put(ctx context.Context, key string, value V, expiration time.Time) { c.Mutex.Lock() defer c.Mutex.Unlock() telemetry.TelemeterFromContext(ctx).Count(ctx, c.Name+"_cache_put", 1) c.Cache[key] = ExpiringItem[V]{Value: value, Expiration: expiration} } // ContextCache returns cache from the context. If the cache is nil, it creates a new instance. func ContextCache[T any](ctx context.Context, key any) *Cache[T] { cacheInstance, ok := ctx.Value(key).(*Cache[T]) if !ok || cacheInstance == nil { cacheInstance = NewCache[T](fmt.Sprintf("%v", key)) } return cacheInstance } ================================================ FILE: internal/cache/cache_test.go ================================================ package cache_test import ( "testing" "time" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/stretchr/testify/assert" ) func TestCacheCreation(t *testing.T) { t.Parallel() cache := cache.NewCache[string]("test") assert.NotNil(t, cache.Mutex) assert.NotNil(t, cache.Cache) assert.Empty(t, cache.Cache) } func TestStringCacheOperation(t *testing.T) { t.Parallel() ctx := t.Context() cache := cache.NewCache[string]("test") value, found := cache.Get(ctx, "potato") assert.False(t, found) assert.Empty(t, value) cache.Put(ctx, "potato", "carrot") value, found = cache.Get(ctx, "potato") assert.True(t, found) assert.NotEmpty(t, value) assert.Equal(t, "carrot", value) } func TestExpiringCacheCreation(t *testing.T) { t.Parallel() cache := cache.NewExpiringCache[string]("test") assert.NotNil(t, cache.Mutex) assert.NotNil(t, cache.Cache) assert.Empty(t, cache.Cache) } func TestExpiringCacheOperation(t *testing.T) { t.Parallel() ctx := t.Context() cache := cache.NewExpiringCache[string]("test") value, found := cache.Get(ctx, "potato") assert.False(t, found) assert.Empty(t, value) cache.Put(ctx, "potato", "carrot", time.Now().Add(1*time.Second)) value, found = cache.Get(ctx, "potato") assert.True(t, found) assert.NotEmpty(t, value) assert.Equal(t, "carrot", value) } func TestExpiringCacheExpiration(t *testing.T) { t.Parallel() ctx := t.Context() cache := cache.NewExpiringCache[string]("test") cache.Put(ctx, "potato", "carrot", time.Now().Add(-1*time.Second)) value, found := cache.Get(ctx, "potato") assert.False(t, found) assert.NotEmpty(t, value) assert.Equal(t, "carrot", value) } ================================================ FILE: internal/cache/context.go ================================================ package cache import ( "context" ) const ( // RunCmdCacheContextKey is the context key used to store and retrieve the run command cache RunCmdCacheContextKey ctxKey = iota // runCmdCacheName is the identifier for the run command cache instance runCmdCacheName = "runCmdCache" ) // ctxKey is a type-safe context key type to prevent key collisions type ctxKey byte func ContextWithCache(ctx context.Context) context.Context { return context.WithValue(ctx, RunCmdCacheContextKey, NewCache[string](runCmdCacheName)) } ================================================ FILE: internal/cas/.gitignore ================================================ *.test ================================================ FILE: internal/cas/benchmark_test.go ================================================ package cas_test import ( "fmt" "os" "path/filepath" "strconv" "sync" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func BenchmarkClone(b *testing.B) { // Use a small, public repository for consistent results repo := "https://github.com/gruntwork-io/terragrunt.git" l := logger.CreateLogger() b.Run("fresh clone", func(b *testing.B) { tempDir := b.TempDir() b.ResetTimer() for i := 0; b.Loop(); i++ { b.StopTimer() storePath := filepath.Join(tempDir, "store", strconv.Itoa(i)) targetPath := filepath.Join(tempDir, "repo", strconv.Itoa(i)) c, err := cas.New(cas.Options{ StorePath: storePath, }) if err != nil { b.Fatal(err) } b.StartTimer() if err := c.Clone(b.Context(), l, &cas.CloneOptions{ Dir: targetPath, }, repo); err != nil { b.Fatal(err) } } }) b.Run("clone with existing store", func(b *testing.B) { tempDir := b.TempDir() storePath := filepath.Join(tempDir, "store") // First clone to populate store c, err := cas.New(cas.Options{ StorePath: storePath, }) if err != nil { b.Fatal(err) } if err := c.Clone(b.Context(), l, &cas.CloneOptions{ Dir: filepath.Join(tempDir, "initial"), }, repo); err != nil { b.Fatal(err) } b.ResetTimer() for i := 0; b.Loop(); i++ { b.StopTimer() targetPath := filepath.Join(tempDir, "repo", strconv.Itoa(i)) c, err := cas.New(cas.Options{ StorePath: storePath, }) if err != nil { b.Fatal(err) } b.StartTimer() if err := c.Clone(b.Context(), l, &cas.CloneOptions{ Dir: targetPath, }, repo); err != nil { b.Fatal(err) } } }) } func BenchmarkContent(b *testing.B) { store := cas.NewStore(b.TempDir()) content := cas.NewContent(store) // Prepare test data testData := []byte("test content for benchmarking") l := logger.CreateLogger() b.Run("store", func(b *testing.B) { for i := 0; b.Loop(); i++ { b.StopTimer() hash := "benchmark" + strconv.Itoa(i) b.StartTimer() if err := content.Store(l, hash, testData); err != nil { b.Fatal(err) } } }) b.Run("parallel_store", func(b *testing.B) { var mu sync.Mutex seen := make(map[string]bool) b.RunParallel(func(pb *testing.PB) { i := 0 for pb.Next() { // Generate unique hash for each goroutine iteration hash := fmt.Sprintf("benchmark%d_%d_%d", b.N, i, time.Now().UnixNano()) mu.Lock() if seen[hash] { mu.Unlock() continue } seen[hash] = true mu.Unlock() if err := content.Store(l, hash, testData); err != nil { b.Fatal(err) } i++ } }) }) } func BenchmarkGitOperations(b *testing.B) { // Setup a git repository for testing repoDir := b.TempDir() g, err := git.NewGitRunner() if err != nil { b.Fatal(err) } g = g.WithWorkDir(repoDir) ctx := b.Context() if err = g.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", false, 1, "main"); err != nil { b.Fatal(err) } b.Run("ls-remote", func(b *testing.B) { g, err = git.NewGitRunner() if err != nil { b.Fatal(err) } g = g.WithWorkDir(repoDir) b.ResetTimer() for b.Loop() { _, err := g.LsRemote(ctx, "https://github.com/gruntwork-io/terragrunt.git", "HEAD") if err != nil { b.Fatal(err) } } }) b.Run("ls-tree -r", func(b *testing.B) { b.ResetTimer() for b.Loop() { _, err := g.LsTreeRecursive(ctx, "HEAD") if err != nil { b.Fatal(err) } } }) b.Run("cat-file", func(b *testing.B) { // First get a valid hash tree, err := g.LsTreeRecursive(ctx, "HEAD") if err != nil { b.Fatal(err) } if len(tree.Entries()) == 0 { b.Fatal("no entries in tree") } hash := tree.Entries()[0].Hash tmpFile := b.TempDir() + "/cat-file" tmp, err := os.Create(tmpFile) if err != nil { b.Fatal(err) } defer os.Remove(tmpFile) defer tmp.Close() b.ResetTimer() for b.Loop() { err := g.CatFile(ctx, hash, tmp) if err != nil { b.Fatal(err) } } }) } ================================================ FILE: internal/cas/cas.go ================================================ // Package cas implements a content-addressable storage for git content. // // Blobs are copied from cloned repositories to a local store, along with trees. // When the same content is requested again, the content is read from the local store, // avoiding the need to clone the repository or read from the network. package cas import ( "context" "crypto/sha1" "encoding/hex" "fmt" "io" "os" "path/filepath" "runtime" "github.com/gofrs/flock" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Options configures the behavior of CAS type Options struct { // StorePath specifies a custom path for the content store // If empty, uses $HOME/.cache/terragrunt/cas/store StorePath string } // CloneOptions configures the behavior of a specific clone operation type CloneOptions struct { // Dir specifies the target directory for the clone // If empty, uses the repository name Dir string // Branch specifies which branch to clone // If empty, uses HEAD Branch string // IncludedGitFiles specifies the files to preserve from the .git directory // If empty, does not preserve any files IncludedGitFiles []string } // CAS clones a git repository using content-addressable storage. type CAS struct { store *Store git *git.GitRunner opts Options } // New creates a new CAS instance with the given options // // TODO: Make these options optional func New(opts Options) (*CAS, error) { if opts.StorePath == "" { home, err := os.UserHomeDir() if err != nil { return nil, err } opts.StorePath = filepath.Join(home, ".cache", "terragrunt", "cas", "store") } if err := os.MkdirAll(opts.StorePath, DefaultDirPerms); err != nil { return nil, fmt.Errorf("failed to create CAS store path: %w", err) } store := NewStore(opts.StorePath) g, err := git.NewGitRunner() if err != nil { return nil, err } return &CAS{ store: store, git: g, opts: opts, }, nil } // Clone performs the clone operation // // TODO: Make options optional func (c *CAS) Clone(ctx context.Context, l log.Logger, opts *CloneOptions, url string) error { // Ensure the store path exists if err := os.MkdirAll(c.store.Path(), DefaultDirPerms); err != nil { return fmt.Errorf("failed to create store path: %w", err) } // Acquire global clone lock to ensure only one clone at a time globalLock := flock.New(filepath.Join(c.store.Path(), "clone.lock")) if err := globalLock.Lock(); err != nil { return fmt.Errorf("failed to acquire global clone lock: %w", err) } defer func() { if unlockErr := globalLock.Unlock(); unlockErr != nil { l.Warnf("failed to release global clone lock: %v", unlockErr) } }() return telemetry.TelemeterFromContext(ctx).Collect(ctx, "cas_clone", map[string]any{ "url": url, "branch": opts.Branch, }, func(childCtx context.Context) error { hash, err := c.resolveReference(childCtx, url, opts.Branch) if err != nil { return err } targetDir := c.prepareTargetDirectory(opts.Dir, url) if c.store.NeedsWrite(hash) { // Create a temporary directory for git operations _, cleanup, createTempDirErr := c.git.CreateTempDir() if createTempDirErr != nil { return createTempDirErr } defer func() { if cleanupErr := cleanup(); cleanupErr != nil { l.Warnf("cleanup error: %v", cleanupErr) } }() if cloneAndStoreErr := c.cloneAndStoreContent(childCtx, l, opts, url, hash); cloneAndStoreErr != nil { return cloneAndStoreErr } } content := NewContent(c.store) treeData, err := content.Read(hash) if err != nil { return err } tree, err := git.ParseTree(treeData, targetDir) if err != nil { return err } return LinkTree(childCtx, c.store, tree, targetDir) }) } func (c *CAS) prepareTargetDirectory(dir, url string) string { targetDir := dir if targetDir == "" { targetDir = git.ExtractRepoName(url) } return filepath.Clean(targetDir) } func (c *CAS) resolveReference(ctx context.Context, url, branch string) (string, error) { results, err := c.git.LsRemote(ctx, url, branch) if err != nil { return "", err } if len(results) == 0 { return "", &WrappedError{ Op: "clone", Context: "no matching reference", Err: ErrNoMatchingReference, } } return results[0].Hash, nil } func (c *CAS) cloneAndStoreContent( ctx context.Context, l log.Logger, opts *CloneOptions, url, hash string, ) error { if err := c.git.Clone(ctx, url, true, 1, opts.Branch); err != nil { return err } return c.storeRootTree(ctx, l, hash, opts) } func (c *CAS) storeRootTree(ctx context.Context, l log.Logger, hash string, opts *CloneOptions) error { tree, err := c.git.LsTreeRecursive(ctx, hash) if err != nil { return err } if err = c.storeTreeRecursive(ctx, l, hash, tree); err != nil { return err } if len(opts.IncludedGitFiles) == 0 { return nil } content := NewContent(c.store) data, err := content.Read(hash) if err != nil { return err } for _, file := range opts.IncludedGitFiles { stat, err := os.Stat(filepath.Join(c.git.WorkDir, file)) if err != nil { return err } if stat.IsDir() { continue } workDirPath := filepath.Join(c.git.WorkDir, file) includedHash, err := hashFile(workDirPath) if err != nil { return err } includedContent := NewContent(c.store) if err := includedContent.EnsureCopy(l, includedHash, workDirPath); err != nil { return err } path := filepath.Join(".git", file) data = append(data, fmt.Appendf(nil, "%06o blob %s\t%s\n", stat.Mode().Perm(), includedHash, path)...) } // Overwrite the root tree with the new data return content.Store(l, hash, data) } // storeTreeRecursive stores a tree fetched from git ls-tree -r func (c *CAS) storeTreeRecursive(ctx context.Context, l log.Logger, hash string, tree *git.Tree) error { if !c.store.NeedsWrite(hash) { return nil } if err := c.storeBlobs(ctx, tree.Entries()); err != nil { return err } // Store the tree object itself content := NewContent(c.store) if err := content.EnsureWithWait(l, hash, tree.Data()); err != nil { return err } return nil } // storeBlobs stores blobs in the CAS func (c *CAS) storeBlobs(ctx context.Context, entries []git.TreeEntry) error { for _, entry := range entries { if !c.store.NeedsWrite(entry.Hash) { continue } if err := c.ensureBlob(ctx, entry.Hash); err != nil { return err } } return nil } // ensureBlob ensures that a blob exists in the CAS. // It doesn't use the standard content.Store method because // we want to take advantage of the ability to write to the // entry using `git cat-file`. func (c *CAS) ensureBlob(ctx context.Context, hash string) error { needsWrite, lock, err := c.store.EnsureWithWait(hash) if err != nil { return err } // If content already exists or was written by another process, we're done if !needsWrite { return nil } // We have the lock and need to write the content defer func() { if unlockErr := lock.Unlock(); unlockErr != nil { err = errors.Join(err, unlockErr) } }() content := NewContent(c.store) tmpHandle, err := content.GetTmpHandle(hash) if err != nil { return err } tmpPath := tmpHandle.Name() // We want to make sure we remove the temporary file // if we encounter an error defer func() { if _, osStatErr := os.Stat(tmpPath); osStatErr == nil { err = errors.Join(err, os.Remove(tmpPath)) } }() err = c.git.CatFile(ctx, hash, tmpHandle) if err != nil { return err } // For Windows, ensure data is synchronized to disk if runtime.GOOS == "windows" { if err = tmpHandle.Sync(); err != nil { return err } } if err = tmpHandle.Close(); err != nil { return err } if err = os.Rename(tmpPath, content.getPath(hash)); err != nil { return err } if err = os.Chmod(content.getPath(hash), StoredFilePerms); err != nil { return err } return nil } func hashFile(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() h := sha1.New() if _, err := io.Copy(h, file); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } ================================================ FILE: internal/cas/cas_test.go ================================================ package cas_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/require" ) func TestCAS_Clone(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("clone new repository", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") targetPath := filepath.Join(tempDir, "repo") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) err = c.Clone(t.Context(), l, &cas.CloneOptions{ Dir: targetPath, }, "https://github.com/gruntwork-io/terragrunt.git") require.NoError(t, err) // Verify repository was cloned _, err = os.Stat(filepath.Join(targetPath, "README.md")) require.NoError(t, err) // Verify nested files were linked _, err = os.Stat(filepath.Join(targetPath, "test", "integration_test.go")) require.NoError(t, err) }) t.Run("clone with specific branch", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") targetPath := filepath.Join(tempDir, "repo") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) err = c.Clone(t.Context(), l, &cas.CloneOptions{ Dir: targetPath, Branch: "main", }, "https://github.com/gruntwork-io/terragrunt.git") require.NoError(t, err) // Verify repository was cloned _, err = os.Stat(filepath.Join(targetPath, "README.md")) require.NoError(t, err) }) t.Run("clone with included git files", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") targetPath := filepath.Join(tempDir, "repo") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) err = c.Clone(t.Context(), l, &cas.CloneOptions{ Dir: targetPath, IncludedGitFiles: []string{"HEAD", "config"}, }, "https://github.com/gruntwork-io/terragrunt.git") require.NoError(t, err) // Verify repository was cloned _, err = os.Stat(filepath.Join(targetPath, ".git", "HEAD")) require.NoError(t, err) _, err = os.Stat(filepath.Join(targetPath, ".git", "config")) require.NoError(t, err) }) } ================================================ FILE: internal/cas/content.go ================================================ package cas import ( "bufio" "context" "io" "os" "path/filepath" "runtime" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Content manages git object storage and linking type Content struct { store *Store } const ( // DefaultDirPerms represents standard directory permissions (rwxr-xr-x) DefaultDirPerms = os.FileMode(0755) // StoredFilePerms represents read-only file permissions (r--r--r--) StoredFilePerms = os.FileMode(0444) // RegularFilePerms represents standard file permissions (rw-r--r--) RegularFilePerms = os.FileMode(0644) // WindowsOS is the name of the Windows operating system WindowsOS = "windows" ) // NewContent creates a new Content instance func NewContent(store *Store) *Content { return &Content{ store: store, } } // Link creates a hard link from the store to the target path func (c *Content) Link(ctx context.Context, hash, targetPath string) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, "cas_link", map[string]any{ "hash": hash, "path": targetPath, }, func(childCtx context.Context) error { sourcePath := c.getPath(hash) // Try to create hard link directly (most efficient path) if err := os.Link(sourcePath, targetPath); err != nil { // Check if it's because target already exists if os.IsExist(err) { // File already exists, which is fine return nil } // If hard link fails for other reasons, try to copy the file data, readErr := os.ReadFile(sourcePath) if readErr != nil { return &WrappedError{ Op: "read_source", Path: sourcePath, Err: ErrReadFile, } } // Write to temporary file first tempPath := targetPath + ".tmp" if err := os.WriteFile(tempPath, data, RegularFilePerms); err != nil { return &WrappedError{ Op: "write_target", Path: tempPath, Err: err, } } // Atomic rename to final path if err := os.Rename(tempPath, targetPath); err != nil { return &WrappedError{ Op: "rename_target", Path: tempPath, Err: err, } } } return nil }) } // Store stores a single content item. This is typically used for trees, // As blobs are written directly from git cat-file stdout. func (c *Content) Store(l log.Logger, hash string, data []byte) error { lock, err := c.store.AcquireLock(hash) if err != nil { return wrapError("acquire_lock", hash, err) } defer func() { if unlockErr := lock.Unlock(); unlockErr != nil { l.Warnf("failed to unlock filesystem lock for hash %s: %v", hash, unlockErr) } }() if err = os.MkdirAll(c.store.Path(), DefaultDirPerms); err != nil { return wrapError("create_store_dir", c.store.Path(), ErrCreateDir) } // Ensure partition directory exists partitionDir := c.getPartition(hash) if err = os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return wrapError("create_partition_dir", partitionDir, ErrCreateDir) } return c.writeContentToFile(l, hash, data) } // Ensure ensures that a content item exists in the store func (c *Content) Ensure(l log.Logger, hash string, data []byte) error { path := c.getPath(hash) if c.store.hasContent(path) { return nil } return c.Store(l, hash, data) } // EnsureWithWait ensures that a content item exists in the store, with optimization // to wait for concurrent writes instead of doing redundant work func (c *Content) EnsureWithWait(l log.Logger, hash string, data []byte) error { needsWrite, lock, err := c.store.EnsureWithWait(hash) if err != nil { return wrapError("ensure_with_wait", hash, err) } // If content already exists or was written by another process, we're done if !needsWrite { return nil } // We have the lock and need to write the content defer func() { if unlockErr := lock.Unlock(); unlockErr != nil { l.Warnf("failed to unlock filesystem lock for hash %s: %v", hash, unlockErr) } }() if err = os.MkdirAll(c.store.Path(), DefaultDirPerms); err != nil { return wrapError("create_store_dir", c.store.Path(), ErrCreateDir) } // Ensure partition directory exists partitionDir := c.getPartition(hash) if err = os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return wrapError("create_partition_dir", partitionDir, ErrCreateDir) } return c.writeContentToFile(l, hash, data) } // writeContentToFile writes data to a temporary file, // sets appropriate permissions, and performs an atomic rename. func (c *Content) writeContentToFile(l log.Logger, hash string, data []byte) error { path := c.getPath(hash) tempPath := path + ".tmp" f, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, RegularFilePerms) if err != nil { return wrapError("create_temp_file", tempPath, err) } buf := bufio.NewWriter(f) if _, err := buf.Write(data); err != nil { f.Close() if removeErr := os.Remove(tempPath); removeErr != nil { l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr) } return wrapError("write_to_store", tempPath, err) } if err := buf.Flush(); err != nil { f.Close() if removeErr := os.Remove(tempPath); removeErr != nil { l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr) } return wrapError("flush_buffer", tempPath, err) } if err := f.Close(); err != nil { if removeErr := os.Remove(tempPath); removeErr != nil { l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr) } return wrapError("close_file", tempPath, err) } // Set read-only permissions on the temporary file if err := os.Chmod(tempPath, StoredFilePerms); err != nil { if removeErr := os.Remove(tempPath); removeErr != nil { l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr) } return wrapError("chmod_temp_file", tempPath, err) } // For Windows, handle readonly attributes specifically if runtime.GOOS == WindowsOS { // Check if a destination file exists and is read-only if _, err := os.Stat(path); err == nil { // File exists, make it writable before rename operation if err := os.Chmod(path, RegularFilePerms); err != nil { l.Warnf("failed to make destination file writable %s: %v", path, err) } } } // Atomic rename if err := os.Rename(tempPath, path); err != nil { if removeErr := os.Remove(tempPath); removeErr != nil { l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr) } return wrapError("finalize_store", path, err) } // For Windows, we need to set the permissions again after rename if runtime.GOOS == WindowsOS { // Ensure the file has read-only permissions after rename if err := os.Chmod(path, StoredFilePerms); err != nil { return wrapError("chmod_final_file", path, err) } } return nil } // EnsureCopy ensures that a content item exists in the store by copying from a file func (c *Content) EnsureCopy(l log.Logger, hash, src string) error { path := c.getPath(hash) if c.store.hasContent(path) { return nil } lock, err := c.store.AcquireLock(hash) if err != nil { return wrapError("acquire_lock", hash, err) } defer func() { if unlockErr := lock.Unlock(); unlockErr != nil { l.Warnf("failed to unlock filesystem lock for hash %s: %v", hash, unlockErr) } }() // Ensure partition directory exists partitionDir := c.getPartition(hash) if err = os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return wrapError("create_partition_dir", partitionDir, ErrCreateDir) } f, err := os.Create(path) if err != nil { return wrapError("create_file", path, err) } defer f.Close() r, err := os.Open(src) if err != nil { return wrapError("open_source", src, err) } defer r.Close() if _, err := io.Copy(f, r); err != nil { return wrapError("copy_file", src, err) } return nil } // GetTmpHandle returns a file handle to a temporary file where content will be stored. func (c *Content) GetTmpHandle(hash string) (*os.File, error) { partitionDir := c.getPartition(hash) if err := os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return nil, wrapError("create_partition_dir", partitionDir, ErrCreateDir) } path := c.getPath(hash) tempPath := path + ".tmp" f, err := os.Create(tempPath) if err != nil { return nil, wrapError("create_temp_file", tempPath, err) } return f, err } // Read retrieves content from the store by hash func (c *Content) Read(hash string) ([]byte, error) { path := c.getPath(hash) return os.ReadFile(path) } // getPartition returns the partition path for a given hash func (c *Content) getPartition(hash string) string { return filepath.Join(c.store.Path(), hash[:2]) } // getPath returns the full path for a given hash func (c *Content) getPath(hash string) string { return filepath.Join(c.getPartition(hash), hash) } ================================================ FILE: internal/cas/content_test.go ================================================ package cas_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testHashValue = "abcdef123456" func TestContent_Store(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("store new content", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") err := content.Store(l, testHash, testData) require.NoError(t, err) // Verify content was stored partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, testData, storedData) }) t.Run("ensure existing content", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") differentData := []byte("different content") // Store content twice err := content.Ensure(l, testHash, testData) require.NoError(t, err) err = content.Ensure(l, testHash, differentData) require.NoError(t, err) // Verify original content remains partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, testData, storedData) }) t.Run("overwrite existing content", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") differentData := []byte("different content") // Store content twice err := content.Store(l, testHash, testData) require.NoError(t, err) err = content.Store(l, testHash, differentData) require.NoError(t, err) // Verify original content remains partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, differentData, storedData) }) } func TestContent_Link(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("create new link", func(t *testing.T) { t.Parallel() storeDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(storeDir) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") // First store some content err := content.Store(l, testHash, testData) require.NoError(t, err) // Then create a link to it targetDir := helpers.TmpDirWOSymlinks(t) targetPath := filepath.Join(targetDir, "test.txt") err = content.Link(t.Context(), testHash, targetPath) require.NoError(t, err) // Verify link was created and contains correct content linkedData, err := os.ReadFile(targetPath) require.NoError(t, err) assert.Equal(t, testData, linkedData) // Verify it's a hard link by checking inode numbers partitionDir := filepath.Join(store.Path(), testHash[:2]) sourceInfo, err := os.Stat(filepath.Join(partitionDir, testHash)) require.NoError(t, err) targetInfo, err := os.Stat(targetPath) require.NoError(t, err) assert.Equal(t, sourceInfo.Sys(), targetInfo.Sys()) }) t.Run("link to existing file", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") // Store content err := content.Store(l, testHash, testData) require.NoError(t, err) // Create target file targetDir := helpers.TmpDirWOSymlinks(t) targetPath := filepath.Join(targetDir, "test.txt") err = os.WriteFile(targetPath, []byte("existing content"), 0644) require.NoError(t, err) // Try to create link err = content.Link(t.Context(), testHash, targetPath) require.NoError(t, err) // Verify original content remains existingData, err := os.ReadFile(targetPath) require.NoError(t, err) assert.Equal(t, []byte("existing content"), existingData) }) } func TestContent_EnsureWithWait(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("content already exists", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := testHashValue testData := []byte("test content") // Store content first err := content.Store(l, testHash, testData) require.NoError(t, err) // EnsureWithWait should not need to write again err = content.EnsureWithWait(l, testHash, []byte("different content")) require.NoError(t, err) // Verify original content remains partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, testData, storedData) }) t.Run("content doesn't exist", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := "newcontent123456" testData := []byte("new test content") // EnsureWithWait should store the content err := content.EnsureWithWait(l, testHash, testData) require.NoError(t, err) // Verify content was stored partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, testData, storedData) }) t.Run("concurrent writes - optimization", func(t *testing.T) { t.Parallel() store := cas.NewStore(helpers.TmpDirWOSymlinks(t)) content := cas.NewContent(store) testHash := "concurrent123456" // Channel to coordinate the test process1Started := make(chan struct{}) process1Done := make(chan struct{}) process2Done := make(chan struct{}) // Process 1: acquires lock first go func() { defer close(process1Done) err := content.EnsureWithWait(l, testHash, []byte("process 1 data")) assert.NoError(t, err) close(process1Started) }() // Process 2: should wait for process 1 and not duplicate work go func() { defer close(process2Done) // Wait for process 1 to start <-process1Started err := content.EnsureWithWait(l, testHash, []byte("process 2 data")) assert.NoError(t, err) }() // Wait for both to complete <-process1Done <-process2Done // Verify only one content exists (from process 1) partitionDir := filepath.Join(store.Path(), testHash[:2]) storedPath := filepath.Join(partitionDir, testHash) storedData, err := os.ReadFile(storedPath) require.NoError(t, err) assert.Equal(t, []byte("process 1 data"), storedData) }) } ================================================ FILE: internal/cas/errors.go ================================================ package cas import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" ) // Error types that can be returned by the cas package type Error string func (e Error) Error() string { return string(e) } const ( // ErrTempDir is returned when failing to create or close a temporary directory ErrTempDir Error = "failed to create or manage temporary directory" // ErrCreateDir is returned when failing to create a directory ErrCreateDir Error = "failed to create directory" // ErrReadFile is returned when failing to read a file ErrReadFile Error = "failed to read file" // ErrGitClone is returned when the git clone operation fails ErrGitClone Error = "failed to complete git clone" ) // WrappedError provides additional context for errors type WrappedError struct { Op string // Operation that failed Path string // File path if applicable Err error // Original error Context string // Additional context } func (e *WrappedError) Error() string { if e.Context != "" { return fmt.Sprintf("%s: %s: %v", e.Op, e.Context, e.Err) } return fmt.Sprintf("%s: %v", e.Op, e.Err) } func (e *WrappedError) Unwrap() error { return e.Err } // Git operation errors var ( ErrCommandSpawn = errors.New("failed to spawn git command") ErrNoMatchingReference = errors.New("no matching reference") ErrReadTree = errors.New("failed to read tree") ErrNoWorkDir = errors.New("working directory not set") ) func wrapError(op, path string, err error) error { return &WrappedError{ Op: op, Path: path, Err: err, } } ================================================ FILE: internal/cas/errors_test.go ================================================ package cas_test import ( "errors" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/stretchr/testify/assert" ) func TestErrorString(t *testing.T) { t.Parallel() tests := []struct { name string err cas.Error want string }{ { name: "temp dir error", err: cas.ErrTempDir, want: "failed to create or manage temporary directory", }, { name: "git clone error", err: cas.ErrGitClone, want: "failed to complete git clone", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.want, tt.err.Error()) }) } } func TestWrappedError(t *testing.T) { t.Parallel() baseErr := errors.New("base error") tests := []struct { name string wrapped *cas.WrappedError want string }{ { name: "with path", wrapped: &cas.WrappedError{ Op: "clone", Path: "/tmp/repo", Err: baseErr, }, want: "clone: base error", }, { name: "with context", wrapped: &cas.WrappedError{ Op: "clone", Context: "repository not found", Err: baseErr, }, want: "clone: repository not found: base error", }, { name: "basic", wrapped: &cas.WrappedError{ Op: "clone", Err: baseErr, }, want: "clone: base error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.want, tt.wrapped.Error()) assert.Equal(t, baseErr, tt.wrapped.Unwrap()) }) } } ================================================ FILE: internal/cas/getter.go ================================================ package cas import ( "context" "fmt" "net/url" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-getter/v2" ) // Assert that CASGetter implements the Getter interface var _ getter.Getter = &CASGetter{} // CASGetter is a go-getter Getter implementation. type CASGetter struct { CAS *CAS Logger log.Logger Opts *CloneOptions Detectors []getter.Detector } func NewCASGetter(l log.Logger, cas *CAS, opts *CloneOptions) *CASGetter { return &CASGetter{ Detectors: []getter.Detector{ new(getter.GitHubDetector), new(getter.GitDetector), new(getter.BitBucketDetector), new(getter.GitLabDetector), new(getter.FileDetector), }, CAS: cas, Logger: l, Opts: opts, } } func (g *CASGetter) Get(ctx context.Context, req *getter.Request) error { if req.Copy { // Handle local directory by persisting to CAS and linking return g.CAS.StoreLocalDirectory(ctx, g.Logger, req.Src, req.Dst) } ref := "" url := req.URL() q := url.Query() if len(q) > 0 { ref = q.Get("ref") q.Del("ref") url.RawQuery = q.Encode() } opts := g.Opts opts.Branch = ref opts.Dir = req.Dst urlStr := url.String() urlStr = strings.TrimPrefix(urlStr, "git::") // We have to switch back to the original URL scheme to clone the repository // go-getter sets the URL like this: // git::ssh://git@github.com/gruntwork-io/terragrunt.git // We need to switch to a valid Git URL to clone the repository // Like this: // git@github.com:gruntwork-io/terragrunt.git if after, ok := strings.CutPrefix(urlStr, "ssh://"); ok { urlStr = after // Replace the first slash with a colon urlStr = strings.Replace(urlStr, "/", ":", 1) } return g.CAS.Clone(ctx, g.Logger, opts, urlStr) } func (g *CASGetter) GetFile(_ context.Context, req *getter.Request) error { return errors.New("GetFile not implemented") } func (g *CASGetter) Mode(_ context.Context, url *url.URL) (getter.Mode, error) { return getter.ModeDir, nil } func (g *CASGetter) Detect(req *getter.Request) (bool, error) { if req.Forced == "git" { return true, nil } if after, ok := strings.CutPrefix(req.Src, "git::"); ok { req.Src = after req.Forced = "git" return true, nil } for _, detector := range g.Detectors { src, ok, err := detector.Detect(req.Src, req.Pwd) if err != nil { return ok, err } if ok { // Check if this is a FileDetector using type assertion if _, isFileDetector := detector.(*getter.FileDetector); isFileDetector { info, statErr := os.Stat(src) if statErr != nil { return false, fmt.Errorf("%w: %s", ErrDirectoryNotFound, src) } if !info.IsDir() { return false, fmt.Errorf("%w: %s", ErrNotADirectory, src) } // We use this as a simple way to indicate that we're working with a local directory. req.Copy = true } req.Src = src return ok, nil } } return false, nil } var ( ErrDirectoryNotFound = errors.New("directory not found") ErrNotADirectory = errors.New("not a directory") ) ================================================ FILE: internal/cas/getter_ssh_test.go ================================================ //go:build ssh // We don't want contributors to have to install SSH keys to run these tests, so we skip // them by default. Contributors need to opt in to run these tests by setting the // build flag `ssh` when running the tests. This is done by adding the `-tags ssh` flag // to the `go test` command. For example: // // go test -tags ssh ./... package cas_test import ( "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/hashicorp/go-getter/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSSHCASGetterGet(t *testing.T) { t.Parallel() tests := []struct { name string url string queryRef string expectRef string }{ { name: "Basic URL without ref", url: "github.com/gruntwork-io/terragrunt", expectRef: "", }, { name: "URL as SSH", url: "git@github.com:gruntwork-io/terragrunt.git", expectRef: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tmpDir, "store") c, err := cas.New(cas.Options{StorePath: storePath}) require.NoError(t, err) opts := &cas.CloneOptions{ Branch: "main", } l := logger.CreateLogger() g := cas.NewCASGetter(l, c, opts) client := getter.Client{ Getters: []getter.Getter{g}, } res, err := client.Get( t.Context(), &getter.Request{ Src: tt.url, Dst: tmpDir, }, ) require.NoError(t, err) assert.Equal(t, tmpDir, res.Dst) }) } } ================================================ FILE: internal/cas/getter_test.go ================================================ package cas_test import ( "net/url" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/hashicorp/go-getter/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCASGetterMode(t *testing.T) { t.Parallel() g := cas.NewCASGetter(nil, nil, &cas.CloneOptions{}) testURL, err := url.Parse("https://github.com/gruntwork-io/terragrunt") require.NoError(t, err) mode, err := g.Mode(t.Context(), testURL) require.NoError(t, err) assert.Equal(t, getter.ModeDir, mode) } func TestCASGetterGetFile(t *testing.T) { t.Parallel() g := cas.NewCASGetter(nil, nil, &cas.CloneOptions{}) err := g.GetFile(t.Context(), &getter.Request{}) require.Error(t, err) assert.Equal(t, "GetFile not implemented", err.Error()) } func TestCASGetterDetect(t *testing.T) { t.Parallel() g := cas.NewCASGetter(nil, nil, &cas.CloneOptions{}) tmp := helpers.TmpDirWOSymlinks(t) os.MkdirAll(filepath.Join(tmp, "fake-module"), 0755) os.WriteFile(filepath.Join(tmp, "fake-module", "main.tf"), []byte(""), 0644) tests := []struct { expectedErr error name string src string pwd string }{ { name: "GitHub repository", src: "github.com/gruntwork-io/terragrunt", pwd: tmp, }, { name: "HTTPS URL repository", src: "git::https://github.com/gruntwork-io/terragrunt", pwd: tmp, }, { name: "Invalid URL", src: "not-a-valid-url", pwd: tmp, expectedErr: cas.ErrDirectoryNotFound, }, { name: "Local directory", src: "./fake-module", pwd: tmp, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() req := &getter.Request{ Src: tt.src, Pwd: tt.pwd, } ok, err := g.Detect(req) if tt.expectedErr != nil { require.ErrorIs(t, err, tt.expectedErr) } else { require.NoError(t, err) assert.True(t, ok) } }) } } func TestCASGetterGet(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) opts := &cas.CloneOptions{ Branch: "main", } l := logger.CreateLogger() g := cas.NewCASGetter(l, c, opts) client := getter.Client{ Getters: []getter.Getter{g}, } tests := []struct { name string url string queryRef string expectRef string }{ { name: "URL with ref parameter", url: "github.com/gruntwork-io/terragrunt?ref=v0.75.0", expectRef: "v0.75.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) res, err := client.Get( t.Context(), &getter.Request{ Src: tt.url, Dst: tmpDir, }, ) require.NoError(t, err) assert.Equal(t, tmpDir, res.Dst) }) } } func TestCASGetterLocalDir(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tmp, "store") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) opts := &cas.CloneOptions{ Branch: "main", } l := logger.CreateLogger() g := cas.NewCASGetter(l, c, opts) fakeModule := filepath.Join(tmp, "fake-module") os.MkdirAll(fakeModule, 0755) fakeModuleSubdir := filepath.Join(fakeModule, "subdir") os.MkdirAll(fakeModuleSubdir, 0755) os.WriteFile(filepath.Join(fakeModule, "main.tf"), []byte(""), 0644) os.WriteFile(filepath.Join(fakeModuleSubdir, "subfile.tf"), []byte(""), 0644) fakeDest := filepath.Join(tmp, "fake-dest") req := &getter.Request{ Src: fakeModule, Dst: fakeDest, Pwd: tmp, } ok, err := g.Detect(req) require.NoError(t, err) assert.True(t, ok) assert.True(t, req.Copy) err = g.Get(t.Context(), req) require.NoError(t, err) stat, err := os.Stat(filepath.Join(fakeDest, "main.tf")) require.NoError(t, err) assert.Equal(t, os.FileMode(0644), stat.Mode()) stat, err = os.Stat(filepath.Join(fakeDest, "subdir", "subfile.tf")) require.NoError(t, err) assert.Equal(t, os.FileMode(0644), stat.Mode()) } ================================================ FILE: internal/cas/integration_test.go ================================================ package cas_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIntegration_CloneAndReuse(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("clone same repo twice uses store", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") // First clone firstClonePath := filepath.Join(tempDir, "first") cas1, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) require.NoError(t, cas1.Clone(t.Context(), l, &cas.CloneOptions{ Dir: firstClonePath, }, "https://github.com/gruntwork-io/terragrunt.git")) // Get info about first clone firstReadme := filepath.Join(firstClonePath, "README.md") firstStat, err := os.Stat(firstReadme) require.NoError(t, err) // Second clone secondClonePath := filepath.Join(tempDir, "second") cas2, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) require.NoError(t, cas2.Clone(t.Context(), l, &cas.CloneOptions{ Dir: secondClonePath, }, "https://github.com/gruntwork-io/terragrunt.git")) // Get info about second clone secondReadme := filepath.Join(secondClonePath, "README.md") secondStat, err := os.Stat(secondReadme) require.NoError(t, err) // Verify both files exist assert.FileExists(t, firstReadme) assert.FileExists(t, secondReadme) // Verify they're hard links using os.SameFile instead of comparing entire Stat_t assert.True(t, os.SameFile(firstStat, secondStat)) }) t.Run("clone with nonexistent branch fails gracefully", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) c, err := cas.New(cas.Options{ StorePath: filepath.Join(tempDir, "store"), }) require.NoError(t, err) err = c.Clone(t.Context(), l, &cas.CloneOptions{ Dir: filepath.Join(tempDir, "repo"), Branch: "nonexistent-branch", }, "https://github.com/gruntwork-io/terragrunt.git") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoMatchingReference) }) t.Run("clone with invalid repository fails gracefully", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) c, err := cas.New(cas.Options{ StorePath: filepath.Join(tempDir, "store"), }) require.NoError(t, err) err = c.Clone(t.Context(), l, &cas.CloneOptions{ Dir: filepath.Join(tempDir, "repo"), }, "https://github.com/yhakbar/nonexistent-repo.git") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrCommandSpawn) }) } func TestIntegration_TreeStorage(t *testing.T) { t.Parallel() ctx := t.Context() l := logger.CreateLogger() t.Run("stores tree objects", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") const testTag = "v0.98.0" // First clone to populate store c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) require.NoError(t, c.Clone(ctx, l, &cas.CloneOptions{ Dir: filepath.Join(tempDir, "repo"), Branch: testTag, }, "https://github.com/gruntwork-io/terragrunt.git")) // Get the commit hash for the tag g, err := git.NewGitRunner() require.NoError(t, err) results, err := g.LsRemote(ctx, "https://github.com/gruntwork-io/terragrunt.git", testTag) require.NoError(t, err) require.NotEmpty(t, results) commitHash := results[0].Hash // Verify the tree object is stored store := cas.NewStore(storePath) require.NoError(t, err) assert.False(t, store.NeedsWrite(commitHash), "Tree object should be stored") // Verify we can read the tree content content := cas.NewContent(store) treeData, err := content.Read(commitHash) require.NoError(t, err) // Parse the tree data to confirm it's valid tree, err := git.ParseTree(treeData, "") require.NoError(t, err) assert.NotEmpty(t, tree.Entries(), "Tree should have entries") }) } ================================================ FILE: internal/cas/local.go ================================================ package cas import ( "context" "crypto/sha1" "encoding/hex" "fmt" "os" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/pkg/log" ) // StoreLocalDirectory persists all content from a local source directory into the CAS // and then links the persisted files to the target directory func (c *CAS) StoreLocalDirectory(ctx context.Context, l log.Logger, sourceDir, targetDir string) error { // Generate a synthetic hash for the local directory based on its contents hash, treeData, err := c.hashDirectory(sourceDir) if err != nil { return fmt.Errorf("failed to hash local directory %s: %w", sourceDir, err) } // Store all files from the directory into the CAS if err = c.storeLocalContent(l, sourceDir, hash, treeData); err != nil { return fmt.Errorf("failed to store local content: %w", err) } // Parse the tree data and link to target directory tree, err := git.ParseTree(treeData, targetDir) if err != nil { return fmt.Errorf("failed to parse local tree: %w", err) } return LinkTree(ctx, c.store, tree, targetDir) } // hashDirectory creates a synthetic hash and tree structure for a local directory func (c *CAS) hashDirectory(sourceDir string) (string, []byte, error) { var treeData []byte var allHashes []string err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // Implicitly handled by tracking the file hashes. if info.IsDir() { return nil } relPath, err := filepath.Rel(sourceDir, path) if err != nil { return err } // Convert to forward slashes for consistency (git-style paths) relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/") fileHash, err := hashFile(path) if err != nil { return fmt.Errorf("failed to hash file %s: %w", path, err) } // Artificially create a tree entry for the file. mode := fmt.Sprintf("%06o", info.Mode().Perm()) treeLine := fmt.Sprintf("%s blob %s\t%s\n", mode, fileHash, relPath) treeData = append(treeData, []byte(treeLine)...) // Collect all hashes for directory hash calculation allHashes = append(allHashes, fileHash) return nil }) if err != nil { return "", nil, err } // Create a synthetic hash for the entire directory based on all file hashes // This ensures the same directory contents always get the same hash dirHash := hashString(strings.Join(allHashes, "")) return dirHash, treeData, nil } // storeLocalContent stores all files from a local directory into the CAS func (c *CAS) storeLocalContent(l log.Logger, sourceDir, dirHash string, treeData []byte) error { // First store the tree object itself content := NewContent(c.store) if err := content.Ensure(l, dirHash, treeData); err != nil { return fmt.Errorf("failed to store tree data: %w", err) } // Walk the directory and store all files return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // Skip directories and the root directory itself if info.IsDir() { return nil } // Hash the file to get its content hash fileHash, err := hashFile(path) if err != nil { return fmt.Errorf("failed to hash file %s: %w", path, err) } if err := content.EnsureCopy(l, fileHash, path); err != nil { return fmt.Errorf("failed to store file %s: %w", path, err) } return nil }) } func hashString(s string) string { h := sha1.New() h.Write([]byte(s)) return hex.EncodeToString(h.Sum(nil)) } ================================================ FILE: internal/cas/race_test.go ================================================ // Tests specific to race conditions are verified here package cas_test import ( "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/hashicorp/go-getter/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCASGetterGetWithRacing(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) storePath := filepath.Join(tempDir, "store") c, err := cas.New(cas.Options{ StorePath: storePath, }) require.NoError(t, err) opts := &cas.CloneOptions{ Branch: "main", } l := logger.CreateLogger() g := cas.NewCASGetter(l, c, opts) client := getter.Client{ Getters: []getter.Getter{g}, } tests := []struct { name string url string queryRef string expectRef string }{ { name: "URL with ref parameter", url: "github.com/gruntwork-io/terragrunt?ref=v0.75.0", expectRef: "v0.75.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) res, err := client.Get( t.Context(), &getter.Request{ Src: tt.url, Dst: tmpDir, }, ) require.NoError(t, err) assert.Equal(t, tmpDir, res.Dst) }) } } ================================================ FILE: internal/cas/store.go ================================================ package cas import ( "os" "path/filepath" "github.com/gofrs/flock" ) // Store manages the store directory and filesystem locks to prevent concurrent writes type Store struct { path string } // NewStore creates a new Store instance. func NewStore(path string) *Store { return &Store{ path: path, } } // Path returns the current store path func (s *Store) Path() string { return s.path } // NeedsWrite checks if a given hash needs to be stored func (s *Store) NeedsWrite(hash string) bool { partitionDir := filepath.Join(s.path, hash[:2]) path := filepath.Join(partitionDir, hash) return !s.hasContent(path) } // HasContent checks if a given hash exists in the store func (s *Store) hasContent(path string) bool { _, err := os.Stat(path) return err == nil } // AcquireLock acquires a filesystem lock for the given hash // Returns the flock instance that should be unlocked when done func (s *Store) AcquireLock(hash string) (*flock.Flock, error) { partitionDir := filepath.Join(s.path, hash[:2]) lockPath := filepath.Join(partitionDir, hash+".lock") // Ensure the partition directory exists if err := os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return nil, err } lock := flock.New(lockPath) if err := lock.Lock(); err != nil { return nil, err } return lock, nil } // TryAcquireLock attempts to acquire a filesystem lock for the given hash without blocking // Returns the flock instance and true if successful, nil and false if the lock is already held func (s *Store) TryAcquireLock(hash string) (*flock.Flock, bool, error) { partitionDir := filepath.Join(s.path, hash[:2]) lockPath := filepath.Join(partitionDir, hash+".lock") // Ensure the partition directory exists if err := os.MkdirAll(partitionDir, DefaultDirPerms); err != nil { return nil, false, err } lock := flock.New(lockPath) acquired, err := lock.TryLock() if err != nil { return nil, false, err } if !acquired { return nil, false, nil } return lock, true, nil } // EnsureWithWait tries to acquire a lock for the given hash, and if another process // is writing the same content, waits for it to complete instead of doing redundant work. // This is an optimization for read operations that avoids duplicate writes. // // Returns: // - needsWrite: true if content doesn't exist and caller should write it // - lock: the acquired lock (nil if needsWrite is false) // - error: any error that occurred func (s *Store) EnsureWithWait(hash string) (needsWrite bool, lock *flock.Flock, err error) { // Fast path: check if content already exists partitionDir := filepath.Join(s.path, hash[:2]) path := filepath.Join(partitionDir, hash) if s.hasContent(path) { return false, nil, nil } // Try to acquire lock without blocking flockLock, acquired, err := s.TryAcquireLock(hash) if err != nil { return false, nil, err } if acquired { // We got the lock immediately, check if we still need to write // (another process might have completed while we were trying) if !s.NeedsWrite(hash) { // Content appeared while we were acquiring lock, no write needed if err = flockLock.Unlock(); err != nil { return false, nil, err } return false, nil, nil } // We have the lock and content doesn't exist, caller should write return true, flockLock, nil } // Lock is held by another process, wait for it to complete waitLock, err := s.AcquireLock(hash) if err != nil { return false, nil, err } // Now we have the lock, check if the other process wrote the content if !s.NeedsWrite(hash) { // Content was written by the other process, no write needed if err := waitLock.Unlock(); err != nil { return false, nil, err } return false, nil, nil } // Content still doesn't exist, caller should write it return true, waitLock, nil } ================================================ FILE: internal/cas/store_test.go ================================================ package cas_test import ( "os" "path/filepath" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStore(t *testing.T) { t.Parallel() t.Run("custom path", func(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) customPath := filepath.Join(tempDir, "custom-store") store := cas.NewStore(customPath) assert.Equal(t, customPath, store.Path()) }) } func TestStore_NeedsWrite(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(tempDir) // Create a fake content file testHash := "abcdef123456" // Create partition directory partitionDir := filepath.Join(store.Path(), testHash[:2]) err := os.MkdirAll(partitionDir, 0755) require.NoError(t, err, "Failed to create partition directory") testPath := filepath.Join(partitionDir, testHash) err = os.WriteFile(testPath, []byte("test"), 0644) require.NoError(t, err, "Failed to create test file") tests := []struct { name string hash string want bool }{ { name: "existing content", hash: testHash, want: false, }, { name: "non-existing content", hash: "nonexistent", want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.want, store.NeedsWrite(tt.hash)) }) } } func TestStore_AcquireLock(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(tempDir) testHash := "abcdef1234567890abcdef1234567890abcdef12" // Test successful lock acquisition lock, err := store.AcquireLock(testHash) require.NoError(t, err) assert.NotNil(t, lock) // Verify lock file exists lockPath := filepath.Join(tempDir, testHash[:2], testHash+".lock") assert.FileExists(t, lockPath) // Clean up err = lock.Unlock() require.NoError(t, err) } func TestStore_TryAcquireLock(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(tempDir) testHash := "abcdef1234567890abcdef1234567890abcdef12" // Test successful lock acquisition lock1, acquired, err := store.TryAcquireLock(testHash) require.NoError(t, err) assert.True(t, acquired) assert.NotNil(t, lock1) // Test lock contention - should fail to acquire lock2, acquired, err := store.TryAcquireLock(testHash) require.NoError(t, err) assert.False(t, acquired) assert.Nil(t, lock2) // Clean up first lock err = lock1.Unlock() require.NoError(t, err) // Now should be able to acquire again lock3, acquired, err := store.TryAcquireLock(testHash) require.NoError(t, err) assert.True(t, acquired) assert.NotNil(t, lock3) // Clean up err = lock3.Unlock() assert.NoError(t, err) } func TestStore_LockConcurrency(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(tempDir) testHash := "abcdef1234567890abcdef1234567890abcdef12" // Test that multiple goroutines can't acquire the same lock done := make(chan bool, 2) acquired := make(chan bool, 2) // First goroutine acquires lock and holds it briefly go func() { lock, err := store.AcquireLock(testHash) assert.NoError(t, err) acquired <- true time.Sleep(100 * time.Millisecond) // Hold lock briefly err = lock.Unlock() assert.NoError(t, err) done <- true }() // Second goroutine tries to acquire the same lock go func() { <-acquired // Wait for first goroutine to acquire lock // Should block until first lock is released start := time.Now() lock, err := store.AcquireLock(testHash) elapsed := time.Since(start) assert.NoError(t, err) assert.Greater(t, elapsed, 50*time.Millisecond, "Second lock should have been blocked") err = lock.Unlock() assert.NoError(t, err) done <- true }() // Wait for both goroutines to complete <-done <-done } func TestStore_EnsureWithWait(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(tempDir) testHash := "abcdef1234567890abcdef1234567890abcdef12" t.Run("content already exists", func(t *testing.T) { t.Parallel() // Create the content manually partitionDir := filepath.Join(tempDir, testHash[:2]) err := os.MkdirAll(partitionDir, 0755) require.NoError(t, err) contentPath := filepath.Join(partitionDir, testHash) err = os.WriteFile(contentPath, []byte("existing content"), 0644) require.NoError(t, err) // EnsureWithWait should return false (no write needed) needsWrite, lock, err := store.EnsureWithWait(testHash) require.NoError(t, err) assert.False(t, needsWrite) assert.Nil(t, lock) }) t.Run("content doesn't exist, no contention", func(t *testing.T) { t.Parallel() testHashNew := "fedcba0987654321fedcba0987654321fedcba09" // EnsureWithWait should return true (write needed) and provide lock needsWrite, lock, err := store.EnsureWithWait(testHashNew) require.NoError(t, err) assert.True(t, needsWrite) assert.NotNil(t, lock) // Clean up err = lock.Unlock() assert.NoError(t, err) }) } ================================================ FILE: internal/cas/tree.go ================================================ package cas import ( "context" "os" "path/filepath" "runtime" "github.com/gruntwork-io/terragrunt/internal/git" "golang.org/x/sync/errgroup" ) // LinkTree writes the tree to a target directory func LinkTree(ctx context.Context, store *Store, t *git.Tree, targetDir string) error { content := NewContent(store) dirsToCreate := make(map[string]struct{}, len(t.Entries())) type workItem struct { itemType string entry git.TreeEntry path string dirPath string } workItems := make([]workItem, 0, len(t.Entries())) for _, entry := range t.Entries() { entryPath := filepath.Join(targetDir, entry.Path) dirPath := filepath.Dir(entryPath) dirsToCreate[dirPath] = struct{}{} // If the parent directory is in dirsToCreate, // we can remove it, since it will be created // when creating the subtree anyways. parentDirPath := filepath.Dir(dirPath) delete(dirsToCreate, parentDirPath) // Create work items based on entry type switch entry.Type { case "blob": workItems = append(workItems, workItem{ itemType: "link", entry: entry, path: entryPath, dirPath: dirPath, }) case "tree": workItems = append(workItems, workItem{ itemType: "subtree", entry: entry, path: entryPath, dirPath: dirPath, }) } } for dirPath := range dirsToCreate { if err := os.MkdirAll(dirPath, DefaultDirPerms); err != nil { return wrapError("mkdir_all", dirPath, err) } } // Use errgroup for concurrent processing g, ctx := errgroup.WithContext(ctx) // Set concurrency limit scalingFactor := 2 maxWorkers := max(1, runtime.NumCPU()/scalingFactor) g.SetLimit(maxWorkers) // Process work items concurrently for _, work := range workItems { g.Go(func() error { switch work.itemType { case "link": err := content.Link(ctx, work.entry.Hash, work.path) if err != nil { return wrapError("link_blob", work.path, err) } case "subtree": treeData, err := content.Read(work.entry.Hash) if err != nil { return wrapError("read_tree", work.entry.Hash, err) } subTree, err := git.ParseTree(treeData, work.path) if err != nil { return wrapError("parse_tree", work.entry.Hash, err) } err = LinkTree(ctx, store, subTree, work.path) if err != nil { return wrapError("link_subtree", work.path, err) } } return nil }) } // Wait for all goroutines to complete and return first error if any return g.Wait() } ================================================ FILE: internal/cas/tree_test.go ================================================ package cas_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseTreeEntry(t *testing.T) { t.Parallel() tests := []struct { name string input string want git.TreeEntry wantErr bool }{ { name: "regular file", input: "100644 blob a1b2c3d4 README.md", want: git.TreeEntry{ Mode: "100644", Type: "blob", Hash: "a1b2c3d4", Path: "README.md", }, }, { name: "executable file", input: "100755 blob e5f6g7h8 scripts/test.sh", want: git.TreeEntry{ Mode: "100755", Type: "blob", Hash: "e5f6g7h8", Path: "scripts/test.sh", }, }, { name: "directory", input: "040000 tree i9j0k1l2 src", want: git.TreeEntry{ Mode: "040000", Type: "tree", Hash: "i9j0k1l2", Path: "src", }, }, { name: "path with spaces", input: "100644 blob m3n4o5p6 path with spaces.txt", want: git.TreeEntry{ Mode: "100644", Type: "blob", Hash: "m3n4o5p6", Path: "path with spaces.txt", }, }, { name: "invalid format", input: "invalid format", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := git.ParseTreeEntry(tt.input) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestParseTree(t *testing.T) { t.Parallel() tests := []struct { name string path string wantPath string input []byte wantLen int wantErr bool }{ { name: "multiple entries", input: []byte(`100644 blob a1b2c3d4 README.md 100755 blob e5f6g7h8 scripts/test.sh 040000 tree i9j0k1l2 src`), path: "test-repo", wantLen: 3, wantPath: "test-repo", }, { name: "empty input", input: []byte(""), path: "empty-repo", wantLen: 0, wantPath: "empty-repo", }, { name: "invalid entry", input: []byte(`100644 blob a1b2c3d4 README.md invalid format`), path: "invalid-repo", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := git.ParseTree(tt.input, tt.path) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Len(t, got.Entries(), tt.wantLen) assert.Equal(t, tt.wantPath, got.Path()) }) } } func TestLinkTree(t *testing.T) { t.Parallel() tests := []struct { name string setupStore func(t *testing.T) (*cas.Store, string) treeData []byte wantFiles []struct { path string hash string content []byte isDir bool } wantErr bool }{ { name: "basic tree with files and directories", setupStore: func(t *testing.T) (*cas.Store, string) { t.Helper() storeDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(storeDir) content := cas.NewContent(store) // Create test content testData := []byte("test content") testHash := "a1b2c3d4" err := content.Store(nil, testHash, testData) require.NoError(t, err) // Create and store the src directory tree data srcTreeData := `100644 blob a1b2c3d4 README.md` srcTreeHash := "i9j0k1l2" err = content.Store(nil, srcTreeHash, []byte(srcTreeData)) require.NoError(t, err) return store, testHash }, treeData: []byte(`100644 blob a1b2c3d4 README.md 100755 blob a1b2c3d4 scripts/test.sh 040000 tree i9j0k1l2 src`), wantFiles: []struct { path string hash string content []byte isDir bool }{ { path: "README.md", content: []byte("test content"), isDir: false, hash: "a1b2c3d4", }, { path: "scripts/test.sh", content: []byte("test content"), isDir: false, hash: "a1b2c3d4", }, { path: "src", isDir: true, }, { path: "src/README.md", content: []byte("test content"), isDir: false, hash: "a1b2c3d4", }, }, }, { name: "empty tree", setupStore: func(t *testing.T) (*cas.Store, string) { t.Helper() storeDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(storeDir) return store, "" }, treeData: []byte(""), wantFiles: []struct { path string hash string content []byte isDir bool }{}, }, { name: "tree with missing content", setupStore: func(t *testing.T) (*cas.Store, string) { t.Helper() storeDir := helpers.TmpDirWOSymlinks(t) store := cas.NewStore(storeDir) return store, "" }, treeData: []byte(`100644 blob missing123 README.md`), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Setup store store, _ := tt.setupStore(t) // Parse the tree tree, err := git.ParseTree(tt.treeData, "test-repo") require.NoError(t, err) // Create target directory targetDir := helpers.TmpDirWOSymlinks(t) // Link the tree err = cas.LinkTree(t.Context(), store, tree, targetDir) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) // Verify all expected files and directories for _, want := range tt.wantFiles { path := filepath.Join(targetDir, want.path) // Check if file/directory exists info, err := os.Stat(path) require.NoError(t, err) assert.Equal(t, want.isDir, info.IsDir()) if !want.isDir { // Check file content data, err := os.ReadFile(path) require.NoError(t, err) assert.Equal(t, want.content, data) dataStat, err := os.Stat(path) require.NoError(t, err) // Verify hard link by comparing content. // We don't compare inode numbers because the test might be running on Windows. storePath := filepath.Join(store.Path(), want.hash[:2], want.hash) storeStat, err := os.Stat(storePath) require.NoError(t, err) assert.True(t, os.SameFile(dataStat, storeStat)) } } }) } } ================================================ FILE: internal/cli/app.go ================================================ // Package cli configures the Terragrunt CLI app and its commands. package cli import ( "context" "fmt" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/internal/telemetry" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/gruntwork-io/terragrunt/internal/cli/commands" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/global" "github.com/gruntwork-io/go-commons/version" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( AppName = "terragrunt" ) func init() { clihelper.AppVersionTemplate = AppVersionTemplate clihelper.AppHelpTemplate = AppHelpTemplate clihelper.CommandHelpTemplate = CommandHelpTemplate } type App struct { *clihelper.App opts *options.TerragruntOptions l log.Logger } // NewApp creates the Terragrunt CLI App. func NewApp(l log.Logger, opts *options.TerragruntOptions) *App { terragruntCommands := commands.New(l, opts) app := clihelper.NewApp() app.Name = AppName app.Usage = "Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.\nFor documentation, see https://docs.terragrunt.com/." app.Author = "Gruntwork " app.Version = version.GetVersion() app.Writer = opts.Writers.Writer app.ErrWriter = opts.Writers.ErrWriter app.Flags = global.NewFlags(l, opts, nil) app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts)) app.Before = beforeAction(opts) app.OsExiter = OSExiter app.ExitErrHandler = ExitErrHandler app.FlagErrHandler = flags.ErrorHandler(terragruntCommands) app.Action = clihelper.ShowAppHelp return &App{app, opts, l} } func (app *App) Run(args []string) error { return app.RunContext(context.Background(), args) } func (app *App) registerGracefullyShutdown(ctx context.Context) context.Context { ctx, cancel := context.WithCancelCause(ctx) signal.NotifierWithContext(ctx, func(sig os.Signal) { // Carriage return helps prevent "^C" from being printed fmt.Fprint(app.Writer, "\r") //nolint:errcheck app.l.Infof("%s signal received. Gracefully shutting down...", cases.Title(language.English).String(sig.String())) cancel(signal.NewContextCanceledError(sig)) }, signal.InterruptSignals...) return ctx } func (app *App) RunContext(ctx context.Context, args []string) error { ctx, cancel := context.WithCancel(ctx) defer cancel() ctx = app.registerGracefullyShutdown(ctx) if err := global.NewTelemetryFlags(app.opts, nil).Parse(os.Args); err != nil { return err } telemeter, err := telemetry.NewTelemeter(ctx, app.Name, app.Version, app.Writer, app.opts.Telemetry) if err != nil { return err } defer func(ctx context.Context) { if err := telemeter.Shutdown(ctx); err != nil { _, _ = app.ErrWriter.Write([]byte(err.Error())) } }(ctx) ctx = telemetry.ContextWithTelemeter(ctx, telemeter) ctx = config.WithConfigValues(ctx) // configure engine context ctx = engine.WithEngineValues(ctx) ctx = run.WithRunVersionCache(ctx) defer func(ctx context.Context) { if err := engine.Shutdown(ctx, app.l, app.opts.Experiments, app.opts.EngineOptions.NoEngine); err != nil { _, _ = app.ErrWriter.Write([]byte(err.Error())) } }(ctx) args = removeNoColorFlagDuplicates(args) if err := app.App.RunContext(ctx, args); err != nil && !errors.IsContextCanceled(err) { return err } return nil } // removeNoColorFlagDuplicates removes one of the `--no-color` or `--terragrunt-no-color` arguments if both are present. // We have to do this because `--terragrunt-no-color` is a deprecated alias for `--no-color`, // therefore we end up specifying the same flag twice, which causes the `setting the flag multiple times` error. func removeNoColorFlagDuplicates(args []string) []string { var ( foundNoColor bool filteredArgs = make([]string, 0, len(args)) ) for _, arg := range args { if strings.HasSuffix(arg, "-"+global.NoColorFlagName) { if foundNoColor { continue } foundNoColor = true } filteredArgs = append(filteredArgs, arg) } return filteredArgs } func beforeAction(_ *options.TerragruntOptions) clihelper.ActionFunc { return func(ctx context.Context, cliCtx *clihelper.Context) error { // setting current context to the options // show help if the args are not specified. if !cliCtx.Args().Present() { err := clihelper.ShowAppHelp(ctx, cliCtx) // exit the app return clihelper.NewExitError(err, 0) } // If args are present but the first non-flag token is not a known // top-level command, fail fast with guidance to use `run --`. // This removes the legacy behavior of implicitly forwarding unknown // commands to OpenTofu/Terraform. cmdName := cliCtx.Args().CommandName() if cmdName != "" { if cliCtx.Command == nil || cliCtx.Command.Subcommand(cmdName) == nil { // Show a clear error pointing users to the explicit run form. // Example: `terragrunt workspace ls` -> suggest `terragrunt run -- workspace ls`. return clihelper.NewExitError( errors.Errorf("unknown command: %q. Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- %s ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command", cmdName, cmdName), clihelper.ExitCodeGeneralError, ) } } return nil } } // OSExiter is an empty function that overrides the default behavior. func OSExiter(exitCode int) { // Do nothing. We just need to override this function, as the default value calls os.Exit, which // kills the app (or any automated test) dead in its tracks. } // ExitErrHandler is an empty function that overrides the default behavior. func ExitErrHandler(_ *clihelper.Context, err error) error { return err } ================================================ FILE: internal/cli/app_test.go ================================================ package cli_test import ( "bytes" "context" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/cli" "github.com/gruntwork-io/terragrunt/internal/cli/commands" awsproviderpatch "github.com/gruntwork-io/terragrunt/internal/cli/commands/aws-provider-patch" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl" hclformat "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/global" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var defaultLogLevel = log.DebugLevel func TestParseTerragruntOptionsFromArgs(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { t.Skip("Skipping test on Windows") } workingDir, err := os.Getwd() if err != nil { t.Fatal(err) } testCases := []struct { expectedErr error expectedOptions *options.TerragruntOptions args []string }{ { args: []string{"plan"}, expectedOptions: mockOptions( t, filepath.Join(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"plan"}, false, "", false, false, defaultLogLevel, false, ), }, { args: []string{"plan", "bar"}, expectedOptions: mockOptions( t, filepath.Join(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"plan", "bar"}, false, "", false, false, defaultLogLevel, false, ), }, { args: []string{"--foo", "--bar"}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"-foo", "-bar"}, false, "", false, false, defaultLogLevel, false, ), expectedErr: clihelper.UndefinedFlagError("foo"), }, { args: []string{"--foo", "apply", "--bar"}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"apply", "-foo", "-bar"}, false, "", false, false, defaultLogLevel, false, ), expectedErr: clihelper.UndefinedFlagError("foo"), }, { args: []string{doubleDashed(global.NonInteractiveFlagName)}, expectedOptions: mockOptions(t, "", "", nil, true, "", false, false, defaultLogLevel, false), }, { args: []string{"apply", doubleDashed(shared.QueueIncludeExternalFlagName)}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"apply"}, false, "", false, true, defaultLogLevel, false, ), }, { args: []string{ "plan", doubleDashed(run.ConfigFlagName), "/some/path/" + config.DefaultTerragruntConfigPath, }, expectedOptions: mockOptions( t, "/some/path/"+config.DefaultTerragruntConfigPath, workingDir, []string{"plan"}, false, "", false, false, defaultLogLevel, false, ), }, { args: []string{"plan", doubleDashed(global.WorkingDirFlagName), "/some/path"}, expectedOptions: mockOptions( t, filepath.Join( "/some/path", config.DefaultTerragruntConfigPath, ), "/some/path", []string{"plan"}, false, "", false, false, defaultLogLevel, false, ), }, { args: []string{"plan", doubleDashed(run.SourceFlagName), "/some/path"}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "/some/path", false, false, defaultLogLevel, false, ), }, { args: []string{ "plan", doubleDashed(run.SourceMapFlagName), "git::git@github.com:one/gw-terraform-aws-vpc.git=git::git@github.com:two/test.git?ref=FEATURE", }, expectedOptions: mockOptionsWithSourceMap( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, map[string]string{ "git::git@github.com:one/gw-terraform-aws-vpc.git": "git::git@github.com:two/test.git?ref=FEATURE", }, ), }, { args: []string{"plan", doubleDashed(shared.QueueIgnoreErrorsFlagName)}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", true, false, defaultLogLevel, false, ), }, { args: []string{"plan", doubleDashed(shared.QueueExcludeExternalFlagName)}, expectedOptions: mockOptions( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", false, false, defaultLogLevel, false, ), }, { args: []string{ "plan", doubleDashed(run.IAMAssumeRoleFlagName), "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME", }, expectedOptions: mockOptionsWithIamRole( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", false, "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME"), }, { args: []string{"plan", doubleDashed(run.IAMAssumeRoleDurationFlagName), "36000"}, expectedOptions: mockOptionsWithIamAssumeRoleDuration( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", false, 36000, ), }, { args: []string{ "plan", doubleDashed(run.IAMAssumeRoleSessionNameFlagName), "terragrunt-iam-role-session-name", }, expectedOptions: mockOptionsWithIamAssumeRoleSessionName( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", false, "terragrunt-iam-role-session-name"), }, { args: []string{ "plan", doubleDashed(run.IAMAssumeRoleWebIdentityTokenFlagName), "web-identity-token", }, expectedOptions: mockOptionsWithIamWebIdentityToken( t, filepath.Join( workingDir, config.DefaultTerragruntConfigPath, ), workingDir, []string{"plan"}, false, "", false, "web-identity-token", ), }, { args: []string{ "plan", doubleDashed(run.ConfigFlagName), "/some/path/" + config.DefaultTerragruntConfigPath, "-non-interactive", }, expectedOptions: mockOptions( t, "/some/path/"+config.DefaultTerragruntConfigPath, workingDir, []string{"plan"}, true, "", false, false, defaultLogLevel, false, ), }, { args: []string{ "plan", doubleDashed(run.ConfigFlagName), "/some/path/" + config.DefaultTerragruntConfigPath, "bar", doubleDashed(global.NonInteractiveFlagName), "--baz", doubleDashed(global.WorkingDirFlagName), "/some/path", doubleDashed(run.SourceFlagName), "github.com/foo/bar//baz?ref=1.0.3", }, expectedOptions: mockOptions( t, "/some/path/"+config.DefaultTerragruntConfigPath, "/some/path", []string{"plan", "bar", "-baz"}, true, "github.com/foo/bar//baz?ref=1.0.3", false, false, defaultLogLevel, false, ), }, // Adding the --terragrunt-log-level flag should result in DebugLevel configured { args: []string{"plan", doubleDashed(global.LogLevelFlagName), "debug"}, expectedOptions: mockOptions( t, filepath.Join(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"plan"}, false, "", false, false, log.DebugLevel, false, ), }, { args: []string{"plan", doubleDashed(run.ConfigFlagName)}, expectedErr: argMissingValueError(run.ConfigFlagName), }, { args: []string{"plan", doubleDashed(global.WorkingDirFlagName)}, expectedErr: argMissingValueError(global.WorkingDirFlagName), }, { args: []string{"plan", "--foo", "bar", doubleDashed(run.ConfigFlagName)}, expectedErr: argMissingValueError(run.ConfigFlagName), }, { args: []string{"plan", doubleDashed(run.InputsDebugFlagName)}, expectedOptions: mockOptions( t, filepath.Join(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"plan"}, false, "", false, false, defaultLogLevel, true, ), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() l := log.New( log.WithOutput(os.Stderr), log.WithLevel(defaultLogLevel), log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) actualOptions, actualErr := runAppTest(l, tc.args, opts) if tc.expectedErr != nil { assert.EqualError(t, actualErr, tc.expectedErr.Error()) } else { require.NoError(t, actualErr) assertOptionsEqual(t, tc.expectedOptions, actualOptions, "For args %v", tc.args) } }) } } // We can't do a direct comparison between TerragruntOptions objects because we can't compare Logger or RunTerragrunt // instances. Therefore, we have to manually check everything else. func assertOptionsEqual(t *testing.T, expected *options.TerragruntOptions, actual *options.TerragruntOptions, msgAndArgs ...any) { t.Helper() assert.Equal(t, expected.TerragruntConfigPath, actual.TerragruntConfigPath, msgAndArgs...) assert.Equal(t, expected.NonInteractive, actual.NonInteractive, msgAndArgs...) assert.Equal(t, expected.TerraformCliArgs, actual.TerraformCliArgs, msgAndArgs...) assert.Equal(t, expected.WorkingDir, actual.WorkingDir, msgAndArgs...) assert.Equal(t, expected.Source, actual.Source, msgAndArgs...) assert.Equal(t, expected.IgnoreDependencyErrors, actual.IgnoreDependencyErrors, msgAndArgs...) assert.Equal(t, expected.IAMRoleOptions, actual.IAMRoleOptions, msgAndArgs...) assert.Equal(t, expected.OriginalIAMRoleOptions, actual.OriginalIAMRoleOptions, msgAndArgs...) assert.Equal(t, expected.Debug, actual.Debug, msgAndArgs...) assert.Equal(t, expected.SourceMap, actual.SourceMap, msgAndArgs...) } func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, _ log.Level, debug bool) *options.TerragruntOptions { t.Helper() opts, err := options.NewTerragruntOptionsForTest(terragruntConfigPath) if err != nil { t.Fatalf("error: %v\n", errors.New(err)) } opts.WorkingDir = workingDir opts.TerraformCliArgs = iacargs.New(terraformCliArgs...) opts.NonInteractive = nonInteractive opts.Source = terragruntSource opts.IgnoreDependencyErrors = ignoreDependencyErrors opts.Debug = debug return opts } func mockOptionsWithIamRole(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, iamRole string) *options.TerragruntOptions { t.Helper() opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) opts.OriginalIAMRoleOptions.RoleARN = iamRole opts.IAMRoleOptions.RoleARN = iamRole return opts } func mockOptionsWithIamAssumeRoleDuration(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, iamAssumeRoleDuration int64) *options.TerragruntOptions { t.Helper() opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) opts.OriginalIAMRoleOptions.AssumeRoleDuration = iamAssumeRoleDuration opts.IAMRoleOptions.AssumeRoleDuration = iamAssumeRoleDuration return opts } func mockOptionsWithIamAssumeRoleSessionName(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, iamAssumeRoleSessionName string) *options.TerragruntOptions { t.Helper() opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) opts.OriginalIAMRoleOptions.AssumeRoleSessionName = iamAssumeRoleSessionName opts.IAMRoleOptions.AssumeRoleSessionName = iamAssumeRoleSessionName return opts } func mockOptionsWithIamWebIdentityToken(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, webIdentityToken string) *options.TerragruntOptions { t.Helper() opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) opts.OriginalIAMRoleOptions.WebIdentityToken = webIdentityToken opts.IAMRoleOptions.WebIdentityToken = webIdentityToken return opts } func mockOptionsWithSourceMap(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, sourceMap map[string]string) *options.TerragruntOptions { t.Helper() opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, false, "", false, false, defaultLogLevel, false) opts.SourceMap = sourceMap return opts } func TestFilterTerragruntArgs(t *testing.T) { t.Parallel() testCases := []struct { args []string expected []string }{ { args: []string{}, expected: []string{}, }, { args: []string{"plan", "--bar"}, expected: []string{"plan", "-bar"}, }, { args: []string{"plan", doubleDashed(run.ConfigFlagName), "/some/path/" + config.DefaultTerragruntConfigPath}, expected: []string{"plan"}, }, { args: []string{"plan", doubleDashed(global.NonInteractiveFlagName)}, expected: []string{"plan"}, }, { args: []string{"plan", doubleDashed(run.InputsDebugFlagName)}, expected: []string{"plan"}, }, { args: []string{ "plan", doubleDashed(global.NonInteractiveFlagName), "-bar", doubleDashed(global.WorkingDirFlagName), "/some/path", "--baz", doubleDashed(run.ConfigFlagName), "/some/path/" + config.DefaultTerragruntConfigPath, }, expected: []string{"plan", "-bar", "-baz"}, }, { args: []string{"run", "--all", "apply", "plan", "bar"}, expected: []string{tf.CommandNameApply, "plan", "bar"}, }, { args: []string{"run", "--all", "destroy", "--", "plan", "-foo", "--bar"}, expected: []string{tf.CommandNameDestroy, "-foo", "-bar", "plan"}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() l := log.New( log.WithOutput(os.Stderr), log.WithLevel(defaultLogLevel), log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) actualOptions, err := runAppTest(l, tc.args, opts) require.NoError(t, err) assert.Equal(t, tc.expected, actualOptions.TerraformCliArgs.Slice(), "For args %v", tc.args) }) } } func TestParseMultiStringArg(t *testing.T) { t.Parallel() flagName := doubleDashed(run.ProviderCacheRegistryNamesFlagName) testCases := []struct { expectedErr error args []string defaultValue []string expectedVals []string }{ { args: []string{"run", "--all", "apply", flagName, "bar"}, defaultValue: []string{"registry.terraform.io", "registry.opentofu.org"}, expectedVals: []string{"bar"}, }, { args: []string{"run", "--all", "apply", "--", "--test", "bar"}, defaultValue: []string{"registry.terraform.io", "registry.opentofu.org"}, expectedVals: []string{"registry.terraform.io", "registry.opentofu.org"}, }, { args: []string{"run", "--all", "plan", flagName, "bar1", flagName, "bar2", "--", "--test", "value"}, defaultValue: []string{"registry.terraform.io", "registry.opentofu.org"}, expectedVals: []string{"bar1", "bar2"}, }, { args: []string{"run", "--all", "plan", flagName, "bar1", flagName, "--", "--test", "value"}, defaultValue: []string{"registry.terraform.io", "registry.opentofu.org"}, expectedErr: argMissingValueError(run.ProviderCacheRegistryNamesFlagName), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() l := log.New( log.WithOutput(os.Stderr), log.WithLevel(defaultLogLevel), log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) actualOptions, actualErr := runAppTest(l, tc.args, opts) if tc.expectedErr != nil { assert.EqualError(t, actualErr, tc.expectedErr.Error()) } else { require.NoError(t, actualErr) assert.Equal(t, tc.expectedVals, actualOptions.ProviderCacheOptions.RegistryNames, "For args %q", tc.args) } }) } } func TestParseMutliStringKeyValueArg(t *testing.T) { t.Parallel() flagName := doubleDashed(awsproviderpatch.OverrideAttrFlagName) testCases := []struct { expectedErr error defaultValue map[string]string expectedVals map[string]string args []string }{ { args: []string{awsproviderpatch.CommandName}, }, { args: []string{awsproviderpatch.CommandName}, defaultValue: map[string]string{"default": "value"}, expectedVals: map[string]string{"default": "value"}, }, { args: []string{awsproviderpatch.CommandName, "--other", "arg"}, defaultValue: map[string]string{"default": "value"}, expectedVals: map[string]string{"default": "value"}, }, { args: []string{awsproviderpatch.CommandName, flagName, "key=value"}, defaultValue: map[string]string{"default": "value"}, expectedVals: map[string]string{"key": "value"}, }, { args: []string{awsproviderpatch.CommandName, flagName, "key1=value1", flagName, "key2=value2", flagName, "key3=value3"}, defaultValue: map[string]string{"default": "value"}, expectedVals: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}, }, { args: []string{awsproviderpatch.CommandName, flagName, "invalidvalue"}, defaultValue: map[string]string{"default": "value"}, expectedErr: clihelper.NewInvalidKeyValueError(clihelper.MapFlagKeyValSep, "invalidvalue"), }, } for _, tc := range testCases { opts := options.NewTerragruntOptions() opts.AwsProviderPatchOverrides = tc.defaultValue l := log.New( log.WithOutput(os.Stderr), log.WithLevel(defaultLogLevel), log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) actualOptions, actualErr := runAppTest(l, tc.args, opts) if tc.expectedErr != nil { assert.ErrorContains(t, actualErr, tc.expectedErr.Error()) } else { require.NoError(t, actualErr) assert.Equal(t, tc.expectedVals, actualOptions.AwsProviderPatchOverrides, "For args %v", tc.args) } } } func TestTerragruntVersion(t *testing.T) { t.Parallel() version := "v1.2.3" testCases := []struct { args []string }{ {[]string{"terragrunt", "--version"}}, {[]string{"terragrunt", "-version"}}, {[]string{"terragrunt", "-v"}}, } for _, tc := range testCases { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) app := cli.NewApp(logger.CreateLogger(), opts) app.Version = version err := app.Run(tc.args) require.NoError(t, err, tc) assert.Contains(t, output.String(), version) } } func TestTerragruntHelp(t *testing.T) { t.Parallel() terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} opts := options.NewTerragruntOptions() app := cli.NewApp(logger.CreateLogger(), opts) testCases := []struct { expected string notExpected string args []string }{ { args: []string{"terragrunt", "--help"}, expected: app.UsageText, notExpected: terragruntPrefix.FlagName(awsproviderpatch.OverrideAttrFlagName), }, { args: []string{"terragrunt", "-help"}, expected: app.UsageText, notExpected: terragruntPrefix.FlagName(awsproviderpatch.OverrideAttrFlagName), }, { args: []string{"terragrunt", "-h"}, expected: app.UsageText, notExpected: terragruntPrefix.FlagName(awsproviderpatch.OverrideAttrFlagName), }, { args: []string{"terragrunt", awsproviderpatch.CommandName, "-h"}, expected: run.ConfigFlagName, notExpected: hcl.CommandName + " " + hclformat.CommandName, }, { args: []string{"terragrunt", run.CommandName, "--help"}, expected: run.CommandName, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) app := cli.NewApp(logger.CreateLogger(), opts) err := app.Run(tc.args) require.NoError(t, err, tc) assert.Contains(t, output.String(), tc.expected) if tc.notExpected != "" { assert.NotContains(t, output.String(), tc.notExpected) } }) } } func TestTerraformHelp(t *testing.T) { t.Parallel() testCases := []struct { expected string args []string }{ {args: []string{"terragrunt", tf.CommandNamePlan, "--help"}, expected: "(?s)Usage: terragrunt \\[global options\\] plan.*-detailed-exitcode"}, {args: []string{"terragrunt", tf.CommandNameApply, "-help"}, expected: "(?s)Usage: terragrunt \\[global options\\] apply.*-destroy"}, {args: []string{"terragrunt", tf.CommandNameApply, "-h"}, expected: "(?s)Usage: terragrunt \\[global options\\] apply.*-destroy"}, } for _, tc := range testCases { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) app := cli.NewApp(logger.CreateLogger(), opts) err := app.Run(tc.args) require.NoError(t, err) assert.Regexp(t, tc.expected, output.String()) } } func TestTerraformHelp_wrongHelpFlag(t *testing.T) { t.Parallel() output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) app := cli.NewApp(logger.CreateLogger(), opts) err := app.Run([]string{"terragrunt", "plan", "help"}) require.Error(t, err) } func setCommandAction(action clihelper.ActionFunc, cmds ...*clihelper.Command) { for _, cmd := range cmds { cmd.Action = action setCommandAction(action, cmd.Subcommands...) } } func runAppTest(l log.Logger, args []string, opts *options.TerragruntOptions) (*options.TerragruntOptions, error) { emptyAction := func(ctx context.Context, cliCtx *clihelper.Context) error { return nil } terragruntCommands := commands.New(l, opts) setCommandAction(emptyAction, terragruntCommands...) app := clihelper.NewApp() app.Writer = &bytes.Buffer{} app.ErrWriter = &bytes.Buffer{} app.Flags = append(global.NewFlags(l, opts, nil), run.NewFlags(l, opts, nil)...) app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts)) app.OsExiter = cli.OSExiter app.Action = func(ctx context.Context, cliCtx *clihelper.Context) error { for _, arg := range cliCtx.Args() { switch { case strings.HasPrefix(arg, "-"): opts.TerraformCliArgs.AppendFlag(arg) case opts.TerraformCliArgs.Command == "": opts.TerraformCliArgs.SetCommand(arg) default: opts.TerraformCliArgs.AppendArgument(arg) } } return nil } app.ExitErrHandler = cli.ExitErrHandler err := app.Run(append([]string{"--"}, args...)) return opts, err } func doubleDashed(name string) string { return "--" + name } type argMissingValueError string func (err argMissingValueError) Error() string { return "flag needs an argument: -" + string(err) } func TestAutocomplete(t *testing.T) { //nolint:paralleltest testCases := []struct { compLine string expectedCompletes []string }{ { "", []string{"hcl", "render", "run"}, }, { "--versio", []string{"--version"}, }, { "render -", []string{"--out", "--with-metadata"}, }, { "run pla", []string{"plan"}, }, } for _, tc := range testCases { t.Setenv("COMP_LINE", "terragrunt "+tc.compLine) output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) app := cli.NewApp(logger.CreateLogger(), opts) app.Commands = app.Commands.FilterByNames([]string{"hcl", "render", "run"}) err := app.Run([]string{"terragrunt"}) require.NoError(t, err) for _, expectedComplete := range tc.expectedCompletes { assert.Contains(t, output.String(), expectedComplete) } } } ================================================ FILE: internal/cli/commands/aws-provider-patch/aws-provider-patch.go ================================================ package awsproviderpatch import ( "context" "encoding/json" "os" "path/filepath" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclwrite" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const defaultKeyParts = 2 func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.RunAll { return runAll(ctx, l, opts) } return runSingle(ctx, l, opts) } func runSingle(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { prepared, err := prepare.PrepareConfig(ctx, l, opts) if err != nil { return err } r := report.NewReport() updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) if err != nil { return err } runCfg := prepared.Cfg.ToRunConfig(l) if err := prepare.PrepareGenerate(l, updatedOpts, runCfg); err != nil { return err } if err := prepare.PrepareInit(ctx, l, opts, updatedOpts, runCfg, r); err != nil { return err } return runAwsProviderPatch(l, updatedOpts) } func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { d := discovery.NewDiscovery(opts.WorkingDir) components, err := d.Discover(ctx, l, opts) if err != nil { return err } units := components.Filter(component.UnitKind).Sort() var errs []error for _, unit := range units { unitOpts := opts.Clone() unitOpts.WorkingDir = unit.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) if err := runSingle(ctx, l, unitOpts); err != nil { if opts.FailFast { return err } l.Errorf("aws-provider-patch failed for %s: %v", unit.Path(), err) errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func runAwsProviderPatch(l log.Logger, opts *options.TerragruntOptions) error { if len(opts.AwsProviderPatchOverrides) == 0 { return errors.New(MissingOverrideAttrError(OverrideAttrFlagName)) } terraformFilesInModules, err := findAllTerraformFilesInModules(opts) if err != nil { return err } for _, terraformFile := range terraformFilesInModules { l.Debugf("Looking at file %s", terraformFile) originalTerraformFileContents, err := util.ReadFileAsString(terraformFile) if err != nil { return err } updatedTerraformFileContents, codeWasUpdated, err := PatchAwsProviderInTerraformCode(originalTerraformFileContents, terraformFile, opts.AwsProviderPatchOverrides) if err != nil { return err } if codeWasUpdated { l.Debugf("Patching AWS provider in %s", terraformFile) if err := util.WriteFileWithSamePermissions(terraformFile, terraformFile, []byte(updatedTerraformFileContents)); err != nil { return err } } } return nil } // TerraformModulesJSON is the format we expect in the .terraform/modules/modules.json file type TerraformModulesJSON struct { Modules []TerraformModule `json:"Modules"` } type TerraformModule struct { Key string `json:"Key"` Source string `json:"Source"` Dir string `json:"Dir"` } // findAllTerraformFiles returns all Terraform source files within the modules being used by this Terragrunt // configuration. To be more specific, it only returns the source files downloaded for module "xxx" { ... } blocks into // the .terraform/modules folder; it does NOT return Terraform files for the top-level (AKA "root") module. // // NOTE: this method supports *.tf and *.tofu files. Terraform/OpenTofu code defined in *.json files is not currently // supported. func findAllTerraformFilesInModules(opts *options.TerragruntOptions) ([]string, error) { // Terraform downloads modules into the .terraform/modules folder. Unfortunately, it downloads not only the module // into that folder, but the entire repo it's in, which can contain lots of other unrelated code we probably don't // want to touch. To find the paths to the actual modules, we read the modules.json file in that folder, which is // a manifest file Terraform uses to track where the modules are within each repo. Note that this is an internal // API, so the way we parse/read this modules.json file may break in future Terraform versions. Note that we // can't use the official HashiCorp code to parse this file, as it's marked internal: // https://github.com/hashicorp/terraform/blob/master/internal/modsdir/manifest.go modulesJSONPath := filepath.Join(opts.DataDir(), "modules", "modules.json") if !util.FileExists(modulesJSONPath) { return nil, nil } modulesJSONContents, err := os.ReadFile(modulesJSONPath) if err != nil { return nil, errors.New(err) } var terraformModulesJSON TerraformModulesJSON if err := json.Unmarshal(modulesJSONContents, &terraformModulesJSON); err != nil { return nil, errors.New(err) } var terraformFiles []string for _, module := range terraformModulesJSON.Modules { if module.Key != "" && module.Dir != "" { moduleAbsPath := module.Dir if !filepath.IsAbs(moduleAbsPath) { moduleAbsPath = filepath.Join(opts.WorkingDir, moduleAbsPath) } moduleFiles, err := util.FindTFFiles(moduleAbsPath) if err != nil { return nil, errors.New(err) } // Filter out JSON files (.tf.json, .tofu.json, or any .json) as hclwrite cannot parse JSON for _, file := range moduleFiles { if !strings.HasSuffix(file, ".json") { terraformFiles = append(terraformFiles, file) } } } } return terraformFiles, nil } // PatchAwsProviderInTerraformCode looks for provider "aws" { ... } blocks in the given Terraform code and overwrites // the attributes in those provider blocks with the given attributes. It returns the new Terraform code and a boolean // true if that code was updated. // // For example, if you passed in the following Terraform code: // // provider "aws" { // region = var.aws_region // } // // And you set attributesToOverride to map[string]string{"region": "us-east-1"}, then this method will return: // // provider "aws" { // region = "us-east-1" // } // // This is a temporary workaround for a Terraform bug (https://github.com/hashicorp/terraform/issues/13018) where // any dynamic values in nested provider blocks are not handled correctly when you call 'terraform import', so by // temporarily hard-coding them, we can allow 'import' to work. func PatchAwsProviderInTerraformCode(terraformCode string, terraformFilePath string, attributesToOverride map[string]string) (string, bool, error) { if len(attributesToOverride) == 0 { return terraformCode, false, nil } hclFile, err := hclwrite.ParseConfig([]byte(terraformCode), terraformFilePath, hcl.InitialPos) if err != nil { return "", false, errors.New(err) } codeWasUpdated := false for _, block := range hclFile.Body().Blocks() { if block.Type() == "provider" && len(block.Labels()) == 1 && block.Labels()[0] == "aws" { for key, value := range attributesToOverride { attributeOverridden, err := overrideAttributeInBlock(block, key, value) if err != nil { return string(hclFile.Bytes()), codeWasUpdated, err } codeWasUpdated = codeWasUpdated || attributeOverridden } } } return string(hclFile.Bytes()), codeWasUpdated, nil } // Override the attribute specified in the given key to the given value in a Terraform block: that is, if the attribute // is already set, then update its value to the new value; if the attribute is not already set, do nothing. This method // returns true if an attribute was overridden and false if nothing was changed. // // Note that you can set attributes within nested blocks by using a dot syntax similar to Terraform addresses: e.g., // ".". // // Examples: // // Assume that block1 is: // // provider "aws" { // region = var.aws_region // assume_role { // role_arn = var.role_arn // } // } // // If you call: // // overrideAttributeInBlock(block1, "region", "eu-west-1") // overrideAttributeInBlock(block1, "assume_role.role_arn", "foo") // // The result would be: // // provider "aws" { // region = "eu-west-1" // assume_role { // role_arn = "foo" // } // } // // Assume block2 is: // // provider "aws" {} // // If you call: // // overrideAttributeInBlock(block2, "region", "eu-west-1") // overrideAttributeInBlock(block2, "assume_role.role_arn", "foo") // // The result would be: // // provider "aws" {} // // Returns an error if the provided value is not valid json. func overrideAttributeInBlock(block *hclwrite.Block, key string, value string) (bool, error) { body, attr := traverseBlock(block, strings.Split(key, ".")) if body == nil || body.GetAttribute(attr) == nil { // We didn't find an existing block or attribute, so there's nothing to override return false, nil } // The cty library requires concrete types, but since the value is user provided, we don't have a way to know the // underlying type. Additionally, the provider block themselves don't give us the typing information either unless // we maintain a mapping of all possible provider configurations (which is unmaintainable). To handle this, we // assume the user provided input is json, and convert to cty that way. valueBytes := []byte(value) ctyType, err := ctyjson.ImpliedType(valueBytes) if err != nil { // Wrap error in a custom error type that has better error messaging to the user. returnErr := TypeInferenceError{value: value, underlyingErr: err} return false, errors.New(returnErr) } ctyVal, err := ctyjson.Unmarshal(valueBytes, ctyType) if err != nil { // Wrap error in a custom error type that has better error messaging to the user. returnErr := MalformedJSONValError{value: value, underlyingErr: err} return false, errors.New(returnErr) } body.SetAttributeValue(attr, ctyVal) return true, nil } // Given a Terraform block and slice of keys, return the body of the block that is indicated by the keys, and the // attribute to set within that body. If the slice is of length one, this method returns the body of the current block // and the one entry in the slice. However, if the slice contains multiple values, those indicate nested blocks, so // this method will recursively descend into those blocks and return the body of the final one and the final entry in // the slice to set on it. If a nested block is specified that doesn't actually exist, this method returns a nil body // and empty string for the attribute. // // Examples: // // Assume block is: // // provider "aws" { // region = var.aws_region // assume_role { // role_arn = var.role_arn // } // } // // traverseBlock(block, []string{"region"}) // // => returns (, "region") // // traverseBlock(block, []string{"assume_role", "role_arn"}) // // => returns (, "role_arn") // // traverseBlock(block, []string{"foo"}) // // => returns (nil, "") // // traverseBlock(block, []string{"assume_role", "foo"}) // // => returns (nil, "") func traverseBlock(block *hclwrite.Block, keyParts []string) (*hclwrite.Body, string) { if block == nil { return nil, "" } if len(keyParts) < defaultKeyParts { return block.Body(), strings.Join(keyParts, "") } blockName := keyParts[0] return traverseBlock(block.Body().FirstMatchingBlock(blockName, nil), keyParts[1:]) } ================================================ FILE: internal/cli/commands/aws-provider-patch/aws-provider-patch_test.go ================================================ package awsproviderpatch_test import ( "testing" awsproviderpatch "github.com/gruntwork-io/terragrunt/internal/cli/commands/aws-provider-patch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const terraformCodeExampleOutputOnly = ` output "hello" { value = "Hello, World" } ` const terraformCodeExampleGcpProvider = ` provider "google" { credentials = file("account.json") project = "my-project-id" region = "us-central1" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsProviderEmptyOriginal = ` provider "aws" { } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsProviderRegionVersionOverridenExpected = ` provider "aws" { region = "eu-west-1" version = "0.3.0" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsProviderRegionVersionOverridenReverseOrderExpected = ` provider "aws" { version = "0.3.0" region = "eu-west-1" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsProviderNonEmptyOriginal = ` provider "aws" { region = var.aws_region version = "0.2.0" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsProviderRegionOverridenVersionNotOverriddenExpected = ` provider "aws" { region = "eu-west-1" version = "0.2.0" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsMultipleProvidersOriginal = ` provider "aws" { region = var.aws_region version = "0.2.0" } provider "aws" { alias = "another" region = var.aws_region version = "0.2.0" } resource "aws_instance" "example" { } provider "google" { credentials = file("account.json") project = "my-project-id" region = "us-central1" } provider "aws" { alias = "yet another" region = var.aws_region } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsMultipleProvidersRegionOverridenExpected = ` provider "aws" { region = "eu-west-1" version = "0.2.0" } provider "aws" { alias = "another" region = "eu-west-1" version = "0.2.0" } resource "aws_instance" "example" { } provider "google" { credentials = file("account.json") project = "my-project-id" region = "us-central1" } provider "aws" { alias = "yet another" region = "eu-west-1" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsMultipleProvidersRegionVersionOverridenExpected = ` provider "aws" { region = "eu-west-1" version = "0.3.0" } provider "aws" { alias = "another" region = "eu-west-1" version = "0.3.0" } resource "aws_instance" "example" { } provider "google" { credentials = file("account.json") project = "my-project-id" region = "us-central1" } provider "aws" { alias = "yet another" region = "eu-west-1" } output "hello" { value = "Hello, World" } ` const terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsOriginal = ` # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = var.aws_region # Make sure comments are maintained # And don't interfere with parsing version = "0.2.0" } # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = var.aws_region # Make sure comments are maintained # And don't interfere with parsing version = "0.2.0" # Make sure comments are maintained # And don't interfere with parsing alias = "secondary" } ` const terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsRegionOverriddenExpected = ` # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = "eu-west-1" # Make sure comments are maintained # And don't interfere with parsing version = "0.2.0" } # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = "eu-west-1" # Make sure comments are maintained # And don't interfere with parsing version = "0.2.0" # Make sure comments are maintained # And don't interfere with parsing alias = "secondary" } ` const terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsRegionVersionOverriddenExpected = ` # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = "eu-west-1" # Make sure comments are maintained # And don't interfere with parsing version = "0.3.0" } # Make sure comments are maintained # And don't interfere with parsing provider "aws" { # Make sure comments are maintained # And don't interfere with parsing region = "eu-west-1" # Make sure comments are maintained # And don't interfere with parsing version = "0.3.0" # Make sure comments are maintained # And don't interfere with parsing alias = "secondary" } ` const terraformCodeExampleAwsOneProviderNestedBlocks = ` provider "aws" { region = var.aws_region assume_role { role_arn = var.role_arn } } ` const terraformCodeExampleAwsOneProviderNestedBlocksRegionRoleArnExpected = ` provider "aws" { region = "eu-west-1" assume_role { role_arn = "nested-override" } } ` func TestPatchAwsProviderInTerraformCodeHappyPath(t *testing.T) { t.Parallel() testCases := []struct { attributesToOverride map[string]string testName string originalTerraformCode string expectedTerraformCode []string expectedCodeWasUpdated bool }{ {testName: "empty", originalTerraformCode: "", attributesToOverride: nil, expectedCodeWasUpdated: false, expectedTerraformCode: []string{""}}, {testName: "empty with attributes", originalTerraformCode: "", attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: false, expectedTerraformCode: []string{""}}, {testName: "no provider", originalTerraformCode: terraformCodeExampleOutputOnly, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleOutputOnly}}, {testName: "no aws provider", originalTerraformCode: terraformCodeExampleGcpProvider, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleGcpProvider}}, {testName: "one empty aws provider, but no overrides", originalTerraformCode: terraformCodeExampleAwsProviderEmptyOriginal, attributesToOverride: nil, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsProviderEmptyOriginal}}, {testName: "one empty aws provider, with region override", originalTerraformCode: terraformCodeExampleAwsProviderEmptyOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsProviderEmptyOriginal}}, {testName: "one empty aws provider, with region, version override", originalTerraformCode: terraformCodeExampleAwsProviderEmptyOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "version": `"0.3.0"`}, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsProviderEmptyOriginal}}, {testName: "one non-empty aws provider, but no overrides", originalTerraformCode: terraformCodeExampleAwsProviderNonEmptyOriginal, attributesToOverride: nil, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsProviderNonEmptyOriginal}}, {testName: "one non-empty aws provider, with region override", originalTerraformCode: terraformCodeExampleAwsProviderNonEmptyOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsProviderRegionOverridenVersionNotOverriddenExpected}}, {testName: "one non-empty aws provider, with region, version override", originalTerraformCode: terraformCodeExampleAwsProviderNonEmptyOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "version": `"0.3.0"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsProviderRegionVersionOverridenExpected, terraformCodeExampleAwsProviderRegionVersionOverridenReverseOrderExpected}}, {testName: "multiple providers, but no overrides", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersOriginal, attributesToOverride: nil, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersOriginal}}, {testName: "multiple providers, with region override", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersRegionOverridenExpected}}, {testName: "multiple providers, with region, version override", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "version": `"0.3.0"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersRegionVersionOverridenExpected}}, {testName: "multiple providers with comments, but no overrides", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsOriginal, attributesToOverride: nil, expectedCodeWasUpdated: false, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsOriginal}}, {testName: "multiple providers with comments, with region override", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsRegionOverriddenExpected}}, {testName: "multiple providers with comments, with region, version override", originalTerraformCode: terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "version": `"0.3.0"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsMultipleProvidersNonEmptyWithCommentsRegionVersionOverriddenExpected}}, {testName: "one provider with nested blocks, with region and role_arn override", originalTerraformCode: terraformCodeExampleAwsOneProviderNestedBlocks, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "assume_role.role_arn": `"nested-override"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsOneProviderNestedBlocksRegionRoleArnExpected}}, {testName: "one provider with nested blocks, with region and role_arn override, plus non-matching overrides", originalTerraformCode: terraformCodeExampleAwsOneProviderNestedBlocks, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "assume_role.role_arn": `"nested-override"`, "should-be": `"ignored"`, "assume_role.should-be": `"ignored"`}, expectedCodeWasUpdated: true, expectedTerraformCode: []string{terraformCodeExampleAwsOneProviderNestedBlocksRegionRoleArnExpected}}, } for _, tc := range testCases { t.Run(tc.testName, func(t *testing.T) { t.Parallel() actualTerraformCode, actualCodeWasUpdated, err := awsproviderpatch.PatchAwsProviderInTerraformCode(tc.originalTerraformCode, "test.tf", tc.attributesToOverride) require.NoError(t, err) assert.Equal(t, tc.expectedCodeWasUpdated, actualCodeWasUpdated) // We check an array of possible expected code here due to possible ordering differences. That is, the // attributes within a provider block are stored in a map, and iteration order on maps is randomized, so // sometimes the provider block might come back with region first, followed by version, but other times, // the order is reversed. For those cases, we pass in multiple possible expected results and check that // one of them matches. assert.Contains(t, tc.expectedTerraformCode, actualTerraformCode) }) } } ================================================ FILE: internal/cli/commands/aws-provider-patch/cli.go ================================================ // Package awsproviderpatch provides the `aws-provider-patch` command. // // The `aws-provider-patch` command finds all Terraform modules nested in the current code (i.e., in the .terraform/modules // folder), looks for provider "aws" { ... } blocks in those modules, and overwrites the attributes in those provider // blocks with the attributes specified in terragrntOptions. // // For example, if were running Terragrunt against code that contained a module: // // module "example" { // source = "" // } // // When you run 'init', Terraform would download the code for that module into .terraform/modules. This function would // scan that module code for provider blocks: // // provider "aws" { // region = var.aws_region // } // // And if AwsProviderPatchOverrides in opts was set to map[string]string{"region": "us-east-1"}, then this // method would update the module code to: // // provider "aws" { // region = "us-east-1" // } // // This is a temporary workaround for a Terraform bug (https://github.com/hashicorp/terraform/issues/13018) where // any dynamic values in nested provider blocks are not handled correctly when you call 'terraform import', so by // temporarily hard-coding them, we can allow 'import' to work. package awsproviderpatch import ( "context" runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "aws-provider-patch" OverrideAttrFlagName = "override-attr" ) func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName) return clihelper.Flags{ flags.NewFlag(&clihelper.MapFlag[string, string]{ Name: OverrideAttrFlagName, EnvVars: tgPrefix.EnvVars(OverrideAttrFlagName), Destination: &opts.AwsProviderPatchOverrides, Usage: "A key=value attribute to override in a provider block as part of the aws-provider-patch command. May be specified multiple times.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("override-attr"), terragruntPrefixControl)), } } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { control := controls.NewDeprecatedCommand(CommandName) opts.StrictControls.FilterByNames(controls.DeprecatedCommands, controls.CLIRedesign, CommandName).AddSubcontrolsToCategory(controls.CLIRedesignCommandsCategoryName, control) cmdFlags := append(runcmd.NewFlags(l, opts, nil), NewFlags(l, opts, nil)...) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Overwrite settings on nested AWS providers to work around a Terraform bug (issue #13018).", Hidden: true, Flags: cmdFlags, Before: func(ctx context.Context, _ *clihelper.Context) error { if err := control.Evaluate(ctx); err != nil { return clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } return nil }, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, DisabledErrorOnUndefinedFlag: true, } return cmd } ================================================ FILE: internal/cli/commands/aws-provider-patch/errors.go ================================================ package awsproviderpatch import "fmt" type MissingOverrideAttrError string func (flagName MissingOverrideAttrError) Error() string { return fmt.Sprintf("You must specify at least one provider attribute to override via the --%s option.", string(flagName)) } type TypeInferenceError struct { underlyingErr error value string } func (err TypeInferenceError) Error() string { val := err.value return fmt.Sprintf(`Could not determine underlying type of JSON string %s. This usually happens when the JSON string is malformed, or if the value is not properly quoted (e.g., "%s"). Underlying error: %s`, val, val, err.underlyingErr) } type MalformedJSONValError struct { underlyingErr error value string } func (err MalformedJSONValError) Error() string { val := err.value return fmt.Sprintf(`Error unmarshaling JSON string %s. This usually happens when the JSON string is malformed, or if the value is not properly quoted (e.g., "%s"). Underlying error: %s`, val, val, err.underlyingErr) } ================================================ FILE: internal/cli/commands/aws-provider-patch/tofu_extensions_test.go ================================================ //go:build tofu package awsproviderpatch_test import ( "os" "path/filepath" "strings" "testing" awsproviderpatch "github.com/gruntwork-io/terragrunt/internal/cli/commands/aws-provider-patch" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const tofuCodeExampleAwsProviderOriginal = ` provider "aws" { region = var.aws_region } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" } ` const tofuCodeExampleAwsProviderRegionOverridden = ` provider "aws" { region = "eu-west-1" } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" } ` const tofuCodeExampleMultipleProvidersOriginal = ` provider "aws" { region = var.aws_region } provider "aws" { alias = "east" region = "us-east-1" } provider "google" { project = "my-project" region = "us-central1" } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" } ` const tofuCodeExampleMultipleProvidersRegionOverridden = ` provider "aws" { region = "eu-west-1" } provider "aws" { alias = "east" region = "eu-west-1" } provider "google" { project = "my-project" region = "us-central1" } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" } ` const tofuCodeExampleNestedBlocksOriginal = ` provider "aws" { region = var.aws_region assume_role { role_arn = "arn:aws:iam::123456789012:role/example" } default_tags { tags = { Environment = "test" } } } ` const tofuCodeExampleNestedBlocksRegionRoleArnOverridden = ` provider "aws" { region = "eu-west-1" assume_role { role_arn = "arn:aws:iam::123456789012:role/overridden" } default_tags { tags = { Environment = "test" } } } ` func TestPatchAwsProviderInTofuCode(t *testing.T) { t.Parallel() testCases := []struct { testName string originalTofuCode string attributesToOverride map[string]string expectedTofuCode []string expectedCodeWasUpdated bool }{ { testName: "empty tofu file", attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedTofuCode: []string{""}, }, { testName: "tofu file with no aws provider", originalTofuCode: `resource "null_resource" "example" {}`, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedTofuCode: []string{`resource "null_resource" "example" {}`}, }, { testName: "tofu file with aws provider - region override", originalTofuCode: tofuCodeExampleAwsProviderOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: true, expectedTofuCode: []string{tofuCodeExampleAwsProviderRegionOverridden}, }, { testName: "tofu file with multiple aws providers - region override", originalTofuCode: tofuCodeExampleMultipleProvidersOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`}, expectedCodeWasUpdated: true, expectedTofuCode: []string{tofuCodeExampleMultipleProvidersRegionOverridden}, }, { testName: "tofu file with nested blocks - region and role_arn override", originalTofuCode: tofuCodeExampleNestedBlocksOriginal, attributesToOverride: map[string]string{"region": `"eu-west-1"`, "assume_role.role_arn": `"arn:aws:iam::123456789012:role/overridden"`}, expectedCodeWasUpdated: true, expectedTofuCode: []string{tofuCodeExampleNestedBlocksRegionRoleArnOverridden}, }, { testName: "tofu file with aws provider - no overrides", originalTofuCode: tofuCodeExampleAwsProviderOriginal, expectedTofuCode: []string{tofuCodeExampleAwsProviderOriginal}, }, } for _, tc := range testCases { t.Run(tc.testName, func(t *testing.T) { t.Parallel() actualTofuCode, actualCodeWasUpdated, err := awsproviderpatch.PatchAwsProviderInTerraformCode( tc.originalTofuCode, "test.tofu", tc.attributesToOverride, ) require.NoError(t, err) assert.Equal(t, tc.expectedCodeWasUpdated, actualCodeWasUpdated) assert.Contains(t, tc.expectedTofuCode, actualTofuCode) }) } } func TestFindAllTerraformFilesIncludesTofuFiles(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) terraformModulesDir := filepath.Join(tmpDir, ".terraform", "modules") require.NoError(t, os.MkdirAll(terraformModulesDir, 0755)) modulesJSON := `{ "Modules": [ { "Key": "", "Source": "", "Dir": "." }, { "Key": "vpc", "Source": "./modules/vpc", "Dir": "modules/vpc" }, { "Key": "security", "Source": "./modules/security", "Dir": "modules/security" } ] }` require.NoError(t, os.WriteFile(filepath.Join(terraformModulesDir, "modules.json"), []byte(modulesJSON), 0644)) modules := map[string][]string{ "modules/vpc": {"main.tf", "variables.tofu", "outputs.tf.json"}, "modules/security": {"main.tofu", "variables.tf", "data.tofu.json"}, } for moduleDir, files := range modules { modulePath := filepath.Join(tmpDir, moduleDir) require.NoError(t, os.MkdirAll(modulePath, 0755)) for _, file := range files { filePath := filepath.Join(modulePath, file) content := "# Test content for " + file require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } } opts, err := options.NewTerragruntOptionsForTest("test.hcl") require.NoError(t, err) opts.WorkingDir = tmpDir allFiles, err := util.FindTFFiles(tmpDir) require.NoError(t, err) var files []string for _, file := range allFiles { if !strings.HasSuffix(file, ".json") { files = append(files, file) } } expectedFiles := []string{ filepath.Join(tmpDir, "modules/vpc/main.tf"), filepath.Join(tmpDir, "modules/vpc/variables.tofu"), filepath.Join(tmpDir, "modules/security/main.tofu"), filepath.Join(tmpDir, "modules/security/variables.tf"), } assert.Len(t, files, len(expectedFiles)) for _, expectedFile := range expectedFiles { assert.Contains(t, files, expectedFile, "Expected file %s not found in results", expectedFile) } for _, file := range files { assert.NotEqual(t, ".json", filepath.Ext(file), "JSON file %s should be excluded", file) } } func TestAwsProviderPatchWithMixedFileTypes(t *testing.T) { t.Parallel() tfContent := `terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-west-2" }` modifiedTfContent, wasUpdated, err := awsproviderpatch.PatchAwsProviderInTerraformCode( tfContent, "main.tf", map[string]string{"region": `"eu-west-1"`}, ) require.NoError(t, err) assert.True(t, wasUpdated) assert.Contains(t, modifiedTfContent, `region = "eu-west-1"`) tofuContent := `provider "aws" { alias = "secondary" region = var.secondary_region } resource "aws_s3_bucket" "primary" { bucket = "${var.environment}-primary-bucket" }` modifiedTofuContent, wasUpdated, err := awsproviderpatch.PatchAwsProviderInTerraformCode( tofuContent, "resources.tofu", map[string]string{"region": `"eu-west-1"`}, ) require.NoError(t, err) assert.True(t, wasUpdated) assert.Contains(t, modifiedTofuContent, `region = "eu-west-1"`) } func TestTofuFileExtensionRecognition(t *testing.T) { t.Parallel() testCases := []struct { filename string description string shouldBeIncluded bool }{ {filename: "main.tf", shouldBeIncluded: true, description: "Standard Terraform file"}, {filename: "main.tofu", shouldBeIncluded: true, description: "OpenTofu file"}, {filename: "variables.tf.json", shouldBeIncluded: true, description: "Terraform JSON file (recognized but filtered out during processing)"}, {filename: "variables.tofu.json", shouldBeIncluded: true, description: "OpenTofu JSON file (recognized but filtered out during processing)"}, {filename: "outputs.tf", shouldBeIncluded: true, description: "Terraform outputs file"}, {filename: "outputs.tofu", shouldBeIncluded: true, description: "OpenTofu outputs file"}, {filename: "providers.tf", shouldBeIncluded: true, description: "Terraform providers file"}, {filename: "providers.tofu", shouldBeIncluded: true, description: "OpenTofu providers file"}, {filename: "terraform.tfvars", shouldBeIncluded: false, description: "Terraform variables file (not a configuration file)"}, {filename: "terragrunt.hcl", shouldBeIncluded: false, description: "Terragrunt configuration file"}, {filename: "README.md", shouldBeIncluded: false, description: "Documentation file"}, {filename: "script.sh", shouldBeIncluded: false, description: "Shell script"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() actualResult := util.IsTFFile(tc.filename) if tc.shouldBeIncluded { assert.True(t, actualResult, "File %s should be recognized as a TF file", tc.filename) } else { assert.False(t, actualResult, "File %s should not be recognized as a TF file", tc.filename) } }) } } ================================================ FILE: internal/cli/commands/backend/bootstrap/bootstrap.go ================================================ // Package bootstrap provides the ability to initialize remote state backend. package bootstrap import ( "context" "fmt" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.RunAll { return runAll(ctx, l, opts) } return runBootstrap(ctx, l, opts) } func runBootstrap(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { _, pctx := configbridge.NewParsingContext(ctx, l, opts) remoteState, err := config.ParseRemoteState(ctx, l, pctx) if err != nil || remoteState == nil { return err } return remoteState.Bootstrap(ctx, l, configbridge.RemoteStateOptsFromOpts(opts)) } func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { d := discovery.NewDiscovery(opts.WorkingDir) components, err := d.Discover(ctx, l, opts) if err != nil { return err } units := components.Filter(component.UnitKind).Sort() var errs []error for _, unit := range units { unitOpts := opts.Clone() unitOpts.WorkingDir = unit.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) unitOpts.OriginalTerragruntConfigPath = unitOpts.TerragruntConfigPath if err := runBootstrap(ctx, l, unitOpts); err != nil { if opts.FailFast { return err } errs = append( errs, fmt.Errorf( "backend bootstrap for unit %s failed: %w", unit.Path(), err, ), ) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } ================================================ FILE: internal/cli/commands/backend/bootstrap/cli.go ================================================ package bootstrap import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const CommandName = "bootstrap" func NewFlags(opts *options.TerragruntOptions) clihelper.Flags { prefix := flags.Prefix{flags.TgPrefix} sharedFlags := clihelper.Flags{ shared.NewConfigFlag(opts, prefix, CommandName), shared.NewDownloadDirFlag(opts, prefix, CommandName), } sharedFlags = append(sharedFlags, shared.NewBackendFlags(opts, prefix)...) sharedFlags = append(sharedFlags, shared.NewFeatureFlags(opts, prefix)...) return sharedFlags } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdFlags := NewFlags(opts) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil), shared.NewFailFastFlag(opts)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Bootstrap OpenTofu/Terraform backend infrastructure.", Flags: cmdFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/backend/cli.go ================================================ // Package backend provides commands for interacting with remote backends. package backend import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend/bootstrap" "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend/delete" "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend/migrate" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const CommandName = "backend" func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Interact with OpenTofu/Terraform backend infrastructure.", Subcommands: clihelper.Commands{ bootstrap.NewCommand(l, opts), delete.NewCommand(l, opts), migrate.NewCommand(l, opts), }, Action: clihelper.ShowCommandHelp, } } ================================================ FILE: internal/cli/commands/backend/delete/cli.go ================================================ package delete import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "delete" BucketFlagName = "bucket" ForceBackendDeleteFlagName = "force" ) func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) sharedFlags := clihelper.Flags{ shared.NewConfigFlag(opts, prefix, CommandName), shared.NewDownloadDirFlag(opts, prefix, CommandName), } sharedFlags = append(sharedFlags, shared.NewBackendFlags(opts, prefix)...) sharedFlags = append(sharedFlags, shared.NewFeatureFlags(opts, prefix)...) return append(sharedFlags, flags.NewFlag(&clihelper.BoolFlag{ Name: BucketFlagName, EnvVars: tgPrefix.EnvVars(BucketFlagName), Usage: "Delete the entire bucket.", Hidden: true, Destination: &opts.DeleteBucket, }), flags.NewFlag(&clihelper.BoolFlag{ Name: ForceBackendDeleteFlagName, EnvVars: tgPrefix.EnvVars(ForceBackendDeleteFlagName), Usage: "Force the backend to be deleted, even if the bucket is not versioned.", Destination: &opts.ForceBackendDelete, }), ) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdFlags := NewFlags(l, opts, nil) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil), shared.NewFailFastFlag(opts)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Delete OpenTofu/Terraform state.", Flags: cmdFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/backend/delete/delete.go ================================================ // Package delete provides the ability to remove remote state files/buckets. package delete import ( "context" "fmt" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.RunAll { return runAll(ctx, l, opts) } return runDelete(ctx, l, opts) } func runDelete(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { _, pctx := configbridge.NewParsingContext(ctx, l, opts) remoteState, err := config.ParseRemoteState(ctx, l, pctx) if err != nil || remoteState == nil { return err } if !opts.ForceBackendDelete { enabled, err := remoteState.IsVersionControlEnabled(ctx, l, configbridge.RemoteStateOptsFromOpts(opts)) if err != nil && !errors.As(err, new(backend.BucketDoesNotExistError)) { return err } if !enabled { return errors.Errorf("bucket is not versioned, refusing to delete backend state. If you are sure you want to delete the backend state anyways, use the --%s flag", ForceBackendDeleteFlagName) } } if opts.DeleteBucket { // TODO: Do an extra check before commenting out the code. //return remoteState.DeleteBucket(ctx, opts) return errors.Errorf("flag -%s is not supported yet", BucketFlagName) } return remoteState.Delete(ctx, l, configbridge.RemoteStateOptsFromOpts(opts)) } func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { d := discovery.NewDiscovery(opts.WorkingDir) components, err := d.Discover(ctx, l, opts) if err != nil { return err } units := components.Filter(component.UnitKind).Sort() var errs []error for _, unit := range units { unitOpts := opts.Clone() unitOpts.WorkingDir = unit.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) if err := runDelete(ctx, l, unitOpts); err != nil { if opts.FailFast { return err } errs = append( errs, fmt.Errorf( "backend delete for unit %s failed: %w", unit.Path(), err, ), ) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } ================================================ FILE: internal/cli/commands/backend/migrate/cli.go ================================================ package migrate import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "migrate" ForceBackendMigrateFlagName = "force" usageText = "terragrunt backend migrate [options] " ) func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) sharedFlags := clihelper.Flags{ shared.NewConfigFlag(opts, prefix, CommandName), shared.NewDownloadDirFlag(opts, prefix, CommandName), } sharedFlags = append(sharedFlags, shared.NewBackendFlags(opts, prefix)...) sharedFlags = append(sharedFlags, shared.NewFeatureFlags(opts, prefix)...) return append(sharedFlags, flags.NewFlag(&clihelper.BoolFlag{ Name: ForceBackendMigrateFlagName, EnvVars: tgPrefix.EnvVars(ForceBackendMigrateFlagName), Usage: "Force the backend to be migrated, even if the bucket is not versioned.", Destination: &opts.ForceBackendMigrate, }), ) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmd := &clihelper.Command{ Name: CommandName, Usage: "Migrate OpenTofu/Terraform state from one location to another.", UsageText: usageText, Flags: NewFlags(l, opts, nil), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { srcPath := cliCtx.Args().First() if srcPath == "" { return errors.New(usageText) } dstPath := cliCtx.Args().Second() if dstPath == "" { return errors.New(usageText) } return Run(ctx, l, srcPath, dstPath, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/backend/migrate/migrate.go ================================================ // Package migrate provides the ability to bootstrap remote state backend. package migrate import ( "context" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *options.TerragruntOptions) error { var err error srcPath, err = util.CanonicalPath(srcPath, opts.WorkingDir) if err != nil { return err } l.Debugf("Source unit path %s", srcPath) dstPath, err = util.CanonicalPath(dstPath, opts.WorkingDir) if err != nil { return err } l.Debugf("Destination unit path %s", dstPath) rnr, err := runner.NewStackRunner(ctx, l, opts) if err != nil { return err } srcUnit := rnr.GetStack().FindUnitByPath(srcPath) if srcUnit == nil { return errors.Errorf("src unit not found at %s", srcPath) } dstUnit := rnr.GetStack().FindUnitByPath(dstPath) if dstUnit == nil { return errors.Errorf("dst unit not found at %s", dstPath) } srcOpts, _, err := runner.BuildUnitOpts(l, opts, srcUnit) if err != nil { return errors.Errorf("failed to build opts for src unit %s: %w", srcPath, err) } dstOpts, _, err := runner.BuildUnitOpts(l, opts, dstUnit) if err != nil { return errors.Errorf("failed to build opts for dst unit %s: %w", dstPath, err) } _, srcPctx := configbridge.NewParsingContext(ctx, l, srcOpts) srcRemoteState, err := config.ParseRemoteState(ctx, l, srcPctx) if err != nil { return err } if srcRemoteState == nil { return errors.Errorf("missing remote state configuration for source module: %s", srcPath) } // ParseRemoteState updates pctx.WorkingDir to point to the .terragrunt-cache // directory (where backend.tf and .terraform/ live) when a terraform source is // configured. Propagate that back so pullState runs in the correct directory. srcOpts.WorkingDir = srcPctx.WorkingDir _, dstPctx := configbridge.NewParsingContext(ctx, l, dstOpts) dstRemoteState, err := config.ParseRemoteState(ctx, l, dstPctx) if err != nil { return err } if dstRemoteState == nil { return errors.Errorf("missing remote state configuration for destination module: %s", dstPath) } // Same for the destination: pushState needs the cache directory. dstOpts.WorkingDir = dstPctx.WorkingDir if !opts.ForceBackendMigrate { enabled, err := srcRemoteState.IsVersionControlEnabled(ctx, l, configbridge.RemoteStateOptsFromOpts(srcOpts)) if err != nil && !errors.As(err, new(backend.BucketDoesNotExistError)) { return err } if !enabled { return errors.Errorf("src bucket is not versioned, refusing to migrate backend state. If you are sure you want to migrate the backend state anyways, use the --%s flag", ForceBackendMigrateFlagName) } } return srcRemoteState.Migrate(ctx, l, configbridge.RemoteStateOptsFromOpts(srcOpts), configbridge.RemoteStateOptsFromOpts(dstOpts), dstRemoteState) } ================================================ FILE: internal/cli/commands/catalog/TESTING.md ================================================ # Catalog CLI Command End-to-End Testing This document describes the comprehensive end-to-end testing implementation for the Terragrunt Catalog CLI command using `teatest` from Charm's experimental testing library. ## Overview Testing the TUI for the `catalog` command is a little tricky, as we can't conveniently have someone actually go in and test the TUI every time we make any change that could impact it. To make sure that we don't break the TUI, we take a layered approach to assuring the command works as expected. 1. The core logic used for the `catalog` command is actually handled in [services/catalog](../../../internal/services/catalog). This package can be tested in isolation with standard unit tests, and we minimize any logic done outside of it to reduce the surface area for testing of the TUI. 2. The TUI itself is tested using `teatest` from Charm's experimental testing library. This library provides a way to generate golden files that can be used to test the TUI to ensure that we don't encounter catastrophic regressions that would prevent loading of the TUI. 3. The `catalog` command initialization is tested in [catalog_test.go](catalog_test.go) to make sure we can setup the CLI command correctly to start up the TUI. ### Golden File Testing with teatest - Uses `teatest.RequireEqualOutput(t, out)` for consistent output testing - Includes `.gitattributes` file to handle golden files properly - Golden files capture the exact TUI output for regression testing ### Waiting Patterns Tests use `teatest.WaitFor()` with appropriate timeouts and check intervals: ```go teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) ``` ## Running the Tests ### Update Golden Files ```bash go test -v ./cli/commands/catalog/tui/ -run TestTUIInitialOutput -update ``` ================================================ FILE: internal/cli/commands/catalog/catalog.go ================================================ package catalog import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Run is the main entry point for the catalog command. // It initializes the catalog service, retrieves modules, and then launches the TUI. func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, repoURL string) error { svc := catalog.NewCatalogService(opts) if repoURL != "" { svc.WithRepoURL(repoURL) } err := svc.Load(ctx, l) if err != nil { l.Error(err) } if len(svc.Modules()) == 0 { return errors.New("no modules found by the catalog service") } return tui.Run(ctx, l, opts, svc) } ================================================ FILE: internal/cli/commands/catalog/catalog_test.go ================================================ package catalog_test import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCatalogCommandInitialization(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) // Create mock repository function for testing mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { // Create a temporary directory structure for testing dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", "")) os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) // Create test modules based on repoURL switch repoURL { case "github.com/gruntwork-io/test-repo-1": readme1Path := filepath.Join(dummyRepoDir, "README.md") os.WriteFile(readme1Path, []byte("# AWS VPC Module\nThis module creates a VPC in AWS with all the necessary components."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "main.tf"), []byte("# VPC terraform configuration"), 0644) default: return nil, fmt.Errorf("unexpected repoURL in mock: %s", repoURL) } return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } // Create a temporary root config file tmpDir := helpers.TmpDirWOSymlinks(t) rootFile := filepath.Join(tmpDir, "root.hcl") err = os.WriteFile(rootFile, []byte(`catalog { urls = [ "github.com/gruntwork-io/test-repo-1", ] }`), 0600) require.NoError(t, err) unitDir := filepath.Join(tmpDir, "unit") os.MkdirAll(unitDir, 0755) opts.TerragruntConfigPath = filepath.Join(unitDir, "terragrunt.hcl") opts.ScaffoldRootFileName = config.RecommendedParentConfigName // Test that the catalog service loads correctly svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo) ctx := t.Context() l := logger.CreateLogger() err = svc.Load(ctx, l) require.NoError(t, err) modules := svc.Modules() assert.Len(t, modules, 1, "should have 1 test module") assert.Equal(t, "AWS VPC Module", modules[0].Title()) // Test that the Run function would not return an error for no modules found // (since we have modules loaded) assert.NotEmpty(t, modules, "catalog should have modules for TUI to display") } ================================================ FILE: internal/cli/commands/catalog/cli.go ================================================ // Package catalog provides the ability to interact with a catalog of OpenTofu/Terraform modules // via the `terragrunt catalog` command. package catalog import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "catalog" ) func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { return shared.NewScaffoldingFlags(opts, prefix) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Launch the user interface for searching and managing your module catalog.", Flags: NewFlags(opts, nil), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { var repoPath string if val := cliCtx.Args().Get(0); val != "" { repoPath = val } if opts.ScaffoldRootFileName == "" { opts.ScaffoldRootFileName = scaffold.GetDefaultRootFileName(ctx, opts) } return Run(ctx, l, opts.OptionsFromContext(ctx), repoPath) }, } } ================================================ FILE: internal/cli/commands/catalog/tui/command/scaffold.go ================================================ // Package command provides the implementation of the terragrunt scaffold command // This command is used to scaffold a new Terragrunt unit in the current directory. package command import ( "context" "io" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) type Scaffold struct { module *module.Module terragruntOptions *options.TerragruntOptions svc catalog.CatalogService logger log.Logger } func NewScaffold(logger log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService, module *module.Module) *Scaffold { return &Scaffold{ module: module, terragruntOptions: opts, svc: svc, logger: logger, } } func (cmd *Scaffold) Run() error { return cmd.svc.Scaffold(context.Background(), cmd.logger, cmd.terragruntOptions, cmd.module) } func (cmd *Scaffold) SetStdin(io.Reader) { } func (cmd *Scaffold) SetStdout(io.Writer) { } func (cmd *Scaffold) SetStderr(io.Writer) { } ================================================ FILE: internal/cli/commands/catalog/tui/components/buttonbar/buttonbar.go ================================================ // Package buttonbar provides a bubbletea component that displays an inline list of buttons. package buttonbar import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // SelectBtnMsg is a message that contains the index of the button to select. type SelectBtnMsg int // ActiveBtnMsg is a message that contains the index of the current active button. type ActiveBtnMsg int const ( defaultButtonNameFmt = "[ %s ]" ) var ( defaultButtonSeparatorStyle = lipgloss.NewStyle().Padding(0, 0, 0, 1) defaultButtonFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) defaultButtonBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ) // ButtonBar is bubbletea component that displays an inline list of buttons. type ButtonBar struct { SeparatorStyle lipgloss.Style FocusedStyle lipgloss.Style BlurredStyle lipgloss.Style nameFmt string buttons []string activeButton int } // New creates a new ButtonBar component. func New(buttons []string) *ButtonBar { return &ButtonBar{ buttons: buttons, activeButton: 0, nameFmt: defaultButtonNameFmt, SeparatorStyle: defaultButtonSeparatorStyle, FocusedStyle: defaultButtonFocusedStyle, BlurredStyle: defaultButtonBlurredStyle, } } // Init implements tea.Model. func (b *ButtonBar) Init() tea.Cmd { b.activeButton = 0 return nil } // Update implements tea.Model. func (b *ButtonBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "tab": b.activeButton = (b.activeButton + 1) % len(b.buttons) cmds = append(cmds, b.activeBtnCmd) case "shift+tab": b.activeButton = (b.activeButton - 1 + len(b.buttons)) % len(b.buttons) cmds = append(cmds, b.activeBtnCmd) } case SelectBtnMsg: btn := int(msg) if btn >= 0 && btn < len(b.buttons) { b.activeButton = int(msg) } } return b, tea.Batch(cmds...) } // View implements tea.Model. func (b *ButtonBar) View() string { s := strings.Builder{} for i, btn := range b.buttons { style := b.BlurredStyle if i == b.activeButton { style = b.FocusedStyle } s.WriteString(fmt.Sprintf(b.nameFmt, style.Render(btn))) if i != len(b.buttons)-1 { s.WriteString(b.SeparatorStyle.String()) } } return s.String() } func (b *ButtonBar) activeBtnCmd() tea.Msg { return ActiveBtnMsg(b.activeButton) } ================================================ FILE: internal/cli/commands/catalog/tui/delegate.go ================================================ package tui import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" ) const ( selectedTitleForegroundColorDark = "#63C5DA" selectedTitleBorderForegroundColorDark = "#63C5DA" selectedDescForegroundColorDark = "#59788E" selectedDescBorderForegroundColorDark = "#63C5DA" ) func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { d := list.NewDefaultDelegate() d.Styles.SelectedTitle. Foreground(lipgloss.AdaptiveColor{Dark: selectedTitleForegroundColorDark}). BorderForeground(lipgloss.AdaptiveColor{Dark: selectedTitleBorderForegroundColorDark}) d.Styles.SelectedDesc = d.Styles.SelectedTitle. Foreground(lipgloss.AdaptiveColor{Dark: selectedDescForegroundColorDark}). BorderForeground(lipgloss.AdaptiveColor{Dark: selectedDescBorderForegroundColorDark}) help := []key.Binding{keys.choose, keys.scaffold} d.ShortHelpFunc = func() []key.Binding { return help } d.FullHelpFunc = func() [][]key.Binding { return [][]key.Binding{help} } return d } ================================================ FILE: internal/cli/commands/catalog/tui/keys.go ================================================ package tui import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" ) // newListKeyMap returns a set of keybindings for the list view. func newListKeyMap() list.KeyMap { return list.KeyMap{ // Browsing. CursorUp: key.NewBinding( key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k/↑/ctrl+p", "move up"), ), CursorDown: key.NewBinding( key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j/↓/ctrl+n", "move down"), ), PrevPage: key.NewBinding( key.WithKeys("h", "left", "pgup", "alt+v"), key.WithHelp("h/←/pgup/alt+v", "prev page"), ), NextPage: key.NewBinding( key.WithKeys("l", "right", "pgdown", "ctrl+v"), key.WithHelp("l/→/pgdn/ctrl+v", "next page"), ), GoToStart: key.NewBinding( key.WithKeys("home", "ctrl+a"), key.WithHelp("home/ctrl+a", "go to start"), ), GoToEnd: key.NewBinding( key.WithKeys("end", "ctrl+e"), key.WithHelp("end/ctrl+e", "go to end"), ), Filter: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "search"), ), ClearFilter: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), ), // Filtering. CancelWhileFiltering: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), AcceptWhileFiltering: key.NewBinding( key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), key.WithHelp("enter", "apply filter"), ), // Toggle help. ShowFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "more"), ), CloseFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "close help"), ), // Quitting. Quit: key.NewBinding( key.WithKeys("q", "esc"), key.WithHelp("q", "quit"), ), ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), } } type delegateKeyMap struct { choose key.Binding scaffold key.Binding } // Additional short help entries. This satisfies the help.KeyMap interface and // is entirely optional. func (d delegateKeyMap) ShortHelp() []key.Binding { //nolint:gocritic return []key.Binding{ d.choose, d.scaffold, } } // Additional full help entries. This satisfies the help.KeyMap interface and // is entirely optional. func (d delegateKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic return [][]key.Binding{ { d.choose, d.scaffold, }, } } // newDelegateKeyMap returns a set of keybindings. func newDelegateKeyMap() *delegateKeyMap { return &delegateKeyMap{ choose: key.NewBinding( key.WithKeys("enter", "ctrl-j"), key.WithHelp("enter/ctrl-j", "choose"), ), scaffold: key.NewBinding( key.WithKeys("S", "s"), key.WithHelp("S", "Scaffold"), ), } } // pagerKeyMap returns a set of keybindings for the pager. It satisfies to the // help.KeyMap interface, which is used to render the menu. type pagerKeyMap struct { viewport.KeyMap help help.Model // Button navigation Navigation key.Binding // Button navigation NavigationBack key.Binding // Select button Choose key.Binding // Run Scaffold command Scaffold key.Binding // Help toggle keybindings. Help key.Binding // The quit keybinding. This won't be caught when filtering. Quit key.Binding // The quit-no-matter-what keybinding. This will be caught when filtering. ForceQuit key.Binding } // ShortHelp returns keybindings to be shown in the mini help view. It's part // of the key.Map interface. func (keys pagerKeyMap) ShortHelp() []key.Binding { //nolint:gocritic return []key.Binding{ keys.Up, keys.Down, keys.Navigation, keys.NavigationBack, keys.Choose, keys.Scaffold, keys.Help, keys.Quit, } } // FullHelp returns keybindings for the expanded help view. It's part of the // key.Map interface. func (keys pagerKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic return [][]key.Binding{ {keys.Up, keys.Down, keys.PageDown, keys.PageUp}, // first column {keys.Navigation, keys.NavigationBack, keys.Choose, keys.Scaffold}, // second column {keys.Help, keys.Quit, keys.ForceQuit}, // third column } } // newPagerKeyMap returns a set of keybindings for the pager view. func newPagerKeyMap() pagerKeyMap { return pagerKeyMap{ KeyMap: viewport.KeyMap{ HalfPageUp: key.NewBinding( key.WithDisabled(), ), HalfPageDown: key.NewBinding( key.WithDisabled(), ), Up: key.NewBinding( key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k/↑/ctrl+p", "move up"), ), Down: key.NewBinding( key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j/↓/ctrl+n", "move down"), ), PageDown: key.NewBinding( key.WithKeys("l", "right", "pgdown", "ctrl+v"), key.WithHelp("l/→/pgdn/ctrl+v", "page down"), ), PageUp: key.NewBinding( key.WithKeys("h", "left", "pgup", "alt+v"), key.WithHelp("h/←/pgup/alt+v", "page up"), ), }, help: help.New(), Navigation: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "navigation"), ), NavigationBack: key.NewBinding( key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "navigation"), ), Choose: key.NewBinding( key.WithKeys("enter", "ctrl-j"), key.WithHelp("enter/ctrl-j", "choose"), ), Scaffold: key.NewBinding( key.WithKeys("S", "s"), key.WithHelp("S", "Scaffold"), ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( key.WithKeys("q", "esc"), key.WithHelp("q", "back to list"), ), ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), } } ================================================ FILE: internal/cli/commands/catalog/tui/model.go ================================================ package tui import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/components/buttonbar" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // sessionState keeps track of the view we are currently on. type sessionState int // button is a button in the buttonbar component. type button int const ( title = "List of Modules" titleForegroundColor = "#A8ACB1" titleBackgroundColor = "#1D252F" ) const ( ListState sessionState = iota PagerState ScaffoldState ) const ( scaffoldBtn button = iota viewSourceBtn ) var ( availableButtons = []button{scaffoldBtn, viewSourceBtn} ) func (b button) String() string { return []string{ "Scaffold", "View Source in Browser", }[b] } type Model struct { List list.Model logger log.Logger terragruntOptions *options.TerragruntOptions SVC catalog.CatalogService selectedModule *module.Module delegateKeys *delegateKeyMap buttonBar *buttonbar.ButtonBar currentPagerButtons []button pagerKeys pagerKeyMap listKeys list.KeyMap viewport viewport.Model activeButton button State sessionState height int width int ready bool } func NewModel(l log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService) Model { var ( modules = svc.Modules() items = make([]list.Item, 0, len(modules)) listKeys = newListKeyMap() delegateKeys = newDelegateKeyMap() pagerKeys = newPagerKeyMap() ) // Make the initial list of items for _, module := range modules { items = append(items, module) } // Setup the list delegate := newItemDelegate(delegateKeys) list := list.New(items, delegate, 0, 0) list.KeyMap = listKeys list.SetFilteringEnabled(true) list.Title = title list.Styles.Title = lipgloss.NewStyle(). Foreground(lipgloss.Color(titleForegroundColor)). Background(lipgloss.Color(titleBackgroundColor)). Padding(0, 1) // Setup the markdown viewer vp := viewport.New(0, 0) // Setup the button bar bs := make([]string, len(availableButtons)) for i, b := range availableButtons { bs[i] = b.String() } bb := buttonbar.New(bs) return Model{ List: list, listKeys: listKeys, delegateKeys: delegateKeys, viewport: vp, buttonBar: bb, pagerKeys: pagerKeys, terragruntOptions: opts, SVC: svc, logger: l, } } // Init implements bubbletea.Model.Init func (m Model) Init() tea.Cmd { //nolint:gocritic return tea.Batch( m.buttonBar.Init(), ) } ================================================ FILE: internal/cli/commands/catalog/tui/model_test.go ================================================ package tui_test import ( "bytes" "context" "fmt" "io" "os" "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Test configuration - color profiles are handled by individual test cases if needed // createMockCatalogService creates a mock catalog service with test modules for testing func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) catalog.CatalogService { t.Helper() mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { // Create a temporary directory structure for testing dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", "")) // Initialize as a proper git repository os.MkdirAll(dummyRepoDir, 0755) // Initialize git repository gitDir := filepath.Join(dummyRepoDir, ".git") os.MkdirAll(gitDir, 0755) os.WriteFile(filepath.Join(gitDir, "config"), fmt.Appendf(nil, `[core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [remote "origin"] url = %s fetch = +refs/heads/*:refs/remotes/origin/* [branch "main"] remote = origin merge = refs/heads/main `, repoURL), 0644) os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644) // Create refs directory structure refsDir := filepath.Join(gitDir, "refs") headsDir := filepath.Join(refsDir, "heads") remotesDir := filepath.Join(refsDir, "remotes", "origin") os.MkdirAll(headsDir, 0755) os.MkdirAll(remotesDir, 0755) // Create a fake commit hash for main branch fakeCommitHash := "1234567890abcdef1234567890abcdef12345678" os.WriteFile(filepath.Join(headsDir, "main"), []byte(fakeCommitHash+"\n"), 0644) os.WriteFile(filepath.Join(remotesDir, "main"), []byte(fakeCommitHash+"\n"), 0644) // Create test modules based on repoURL switch repoURL { case "github.com/gruntwork-io/test-repo-1": readme1Path := filepath.Join(dummyRepoDir, "README.md") os.WriteFile(readme1Path, []byte("# AWS VPC Module\nThis module creates a VPC in AWS with all the necessary components."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "main.tf"), []byte("# VPC terraform configuration"), 0644) case "github.com/gruntwork-io/test-repo-2": readme2Path := filepath.Join(dummyRepoDir, "README.md") os.WriteFile(readme2Path, []byte("# AWS EKS Module\nThis module creates an EKS cluster in AWS."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "main.tf"), []byte("# EKS terraform configuration"), 0644) default: return nil, fmt.Errorf("unexpected repoURL in mock: %s", repoURL) } return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } // Create a temporary root config file tmpDir := helpers.TmpDirWOSymlinks(t) rootFile := filepath.Join(tmpDir, "root.hcl") err := os.WriteFile(rootFile, []byte(`catalog { urls = [ "github.com/gruntwork-io/test-repo-1", "github.com/gruntwork-io/test-repo-2", ] }`), 0600) require.NoError(t, err) unitDir := filepath.Join(tmpDir, "unit") os.MkdirAll(unitDir, 0755) opts.TerragruntConfigPath = filepath.Join(unitDir, "terragrunt.hcl") opts.ScaffoldRootFileName = config.RecommendedParentConfigName svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo) // Load the modules ctx := t.Context() l := logger.CreateLogger() err = svc.Load(ctx, l) require.NoError(t, err) return svc } func TestTUIFinalModel(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) svc := createMockCatalogService(t, opts) l := logger.CreateLogger() m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*2)) fm := tm.FinalModel(t) finalModel, ok := fm.(tui.Model) require.True(t, ok, "final model should be of type tui.Model, got %T", fm) // Verify the model has the expected state assert.Equal(t, tui.ListState, finalModel.State) assert.NotNil(t, finalModel.SVC) assert.NotNil(t, finalModel.List) assert.Len(t, finalModel.SVC.Modules(), 2, "should have 2 test modules") } func TestTUIInitialOutput(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) svc := createMockCatalogService(t, opts) l := logger.CreateLogger() m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) // Send 'q' to quit immediately for consistent output tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) // Test that we get the expected output out, err := io.ReadAll(tm.FinalOutput(t)) require.NoError(t, err) teatest.RequireEqualOutput(t, out) } func TestTUINavigationToModuleDetails(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) svc := createMockCatalogService(t, opts) l := logger.CreateLogger() m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) // Wait for initial render teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Press Enter to select the first module (assuming it's pre-selected) tm.Send(tea.KeyMsg{ Type: tea.KeyEnter, }) // Wait for the pager view to appear teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { output := string(bts) // Check for pager view elements (scroll percentage, button bar) return strings.Contains(output, "%") && (strings.Contains(output, "Scaffold") || strings.Contains(output, "View Source")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) // Send 'q' to go back to list tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) // Wait for return to list view teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Finally quit the application tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*2)) } func TestTUIModuleFiltering(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) svc := createMockCatalogService(t, opts) l := logger.CreateLogger() m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) // Wait for initial render teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Activate filtering with '/' tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("/"), }) // Type filter text tm.Type("VPC") // Wait for filtering to take effect teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { output := string(bts) // Should show filtered results containing "VPC" return strings.Contains(strings.ToUpper(output), "VPC") }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) // Press Escape to exit filtering tm.Send(tea.KeyMsg{ Type: tea.KeyEsc, }) // Wait for return to normal list view teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { output := string(bts) // Should show both modules again return strings.Contains(output, "VPC") && strings.Contains(output, "EKS") }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Quit the application tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*2)) } func TestTUIWindowResize(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) svc := createMockCatalogService(t, opts) l := logger.CreateLogger() m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 30)) // Wait for initial render teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Send window resize message tm.Send(tea.WindowSizeMsg{Width: 120, Height: 40}) // Verify the interface handles resize gracefully teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) // Quit tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("q"), }) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*2)) } // TestTUIScaffoldWithRealRepository tests scaffold functionality using a real git repository // This test requires network access and may be slower, but provides more realistic testing func TestTUIScaffoldWithRealRepository(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) // Create a temp directory for scaffold output tempDir := helpers.TmpDirWOSymlinks(t) opts.WorkingDir = tempDir opts.ScaffoldRootFileName = config.RecommendedParentConfigName opts.ScaffoldVars = []string{"EnableRootInclude=false"} // Use real terraform-fake-modules repository svc := catalog.NewCatalogService(opts).WithRepoURL("https://github.com/gruntwork-io/terraform-fake-modules.git") // Load modules from the real repository ctx := t.Context() l := logger.CreateLogger() err = svc.Load(ctx, l) require.NoError(t, err) modules := svc.Modules() require.NotEmpty(t, modules, "should have modules from real repository") m := tui.NewModel(l, opts, svc) tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) // Wait for initial render teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { return bytes.Contains(bts, []byte("List of Modules")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) // Press 'S' to scaffold the first module tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("S"), }) // Wait for scaffold to complete - the application should quit after scaffolding tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*10)) fm := tm.FinalModel(t) finalModel, ok := fm.(tui.Model) require.True(t, ok, "final model should be of type model") // Verify the model transitioned to ScaffoldState assert.Equal(t, tui.ScaffoldState, finalModel.State) assert.NotNil(t, finalModel.SVC) assert.NotEmpty(t, finalModel.SVC.Modules()) // Verify that a terragrunt.hcl file was actually created terragruntFile := filepath.Join(tempDir, "terragrunt.hcl") assert.FileExists(t, terragruntFile, "scaffold should create terragrunt.hcl file") } ================================================ FILE: internal/cli/commands/catalog/tui/testdata/TestTUIInitialOutput.golden ================================================ [?25l[?2004h List of Modules   2 items   │ AWS VPC Module  │ This module creates a VPC in AWS with all the necessary components.   AWS EKS Module  This module creates an EKS cluster in AWS.                              k/↑/ctrl+p move up • j/↓/ctrl+n move down • enter/ctrl-j choose • S Scaffold • / search • q quit • ? more [?2004l[?25h[?1002l[?1003l[?1006l ================================================ FILE: internal/cli/commands/catalog/tui/tui.go ================================================ // Package tui provides a text-based user interface for the Terragrunt catalog command. package tui import ( "context" "errors" tea "github.com/charmbracelet/bubbletea" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService) error { if _, err := tea.NewProgram(NewModel(l, opts, svc), tea.WithAltScreen(), tea.WithContext(ctx)).Run(); err != nil { if err := context.Cause(ctx); errors.Is(err, context.Canceled) { return nil } else if err != nil { return err } return err } return nil } ================================================ FILE: internal/cli/commands/catalog/tui/update.go ================================================ package tui import ( "fmt" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/pkg/browser" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/command" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/components/buttonbar" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/log" ) func updateList(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic var ( cmd tea.Cmd cmds []tea.Cmd ) switch msg := msg.(type) { case tea.KeyMsg: // Don't match any of the keys below if we're actively filtering. if m.List.FilterState() == list.Filtering { break } switch { case key.Matches(msg, m.delegateKeys.choose, m.delegateKeys.scaffold): if selectedModule, ok := m.List.SelectedItem().(*module.Module); ok { switch { case key.Matches(msg, m.delegateKeys.choose): // prepare the viewport var content string if selectedModule.IsMarkDown() { renderer, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithWordWrap(m.width), ) if err != nil { return m, rendererErrCmd(err) } md, err := renderer.Render(selectedModule.Content(false)) if err != nil { return m, rendererErrCmd(err) } content = md } else { content = selectedModule.Content(true) } m.viewport.SetContent(content) // Dynamically create button bar based on module URL var pagerButtons []button buttonNames := []string{} // Always add scaffold button pagerButtons = append(pagerButtons, scaffoldBtn) buttonNames = append(buttonNames, scaffoldBtn.String()) if selectedModule.URL() != "" { pagerButtons = append(pagerButtons, viewSourceBtn) buttonNames = append(buttonNames, viewSourceBtn.String()) } m.currentPagerButtons = pagerButtons m.buttonBar = buttonbar.New(buttonNames) // Ensure the button bar is initialized cmds = append(cmds, m.buttonBar.Init()) // advance state m.selectedModule = selectedModule m.State = PagerState case key.Matches(msg, m.delegateKeys.scaffold): m.State = ScaffoldState return m, scaffoldModuleCmd(m.logger, m, m.SVC, selectedModule) } } else { break } case key.Matches(msg, m.listKeys.Quit): // because we're on the first screen, we simply quit at this point return m, tea.Quit } } // Handle keyboard and mouse events for the list m.List, cmd = m.List.Update(msg) // Append any commands from button bar initialization if len(cmds) > 0 { return m, tea.Batch(cmd, tea.Batch(cmds...)) } return m, cmd } func updatePager(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic var ( cmd tea.Cmd cmds []tea.Cmd ) switch msg := msg.(type) { case tea.KeyMsg: bbModel, barCmd := m.buttonBar.Update(msg) if newButtonBar, ok := bbModel.(*buttonbar.ButtonBar); ok { m.buttonBar = newButtonBar } if barCmd != nil { cmds = append(cmds, barCmd) } switch { case key.Matches(msg, m.pagerKeys.Choose): // Choose changes the action depending on the active button // m.activeButton is set by ActiveBtnMsg, which is mapped from m.currentPagerButtons currentAction := m.activeButton switch currentAction { case scaffoldBtn: m.State = ScaffoldState return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) case viewSourceBtn: if m.selectedModule.URL() != "" { if err := browser.OpenURL(m.selectedModule.URL()); err != nil { m.viewport.SetContent(fmt.Sprintf("could not open url in browser: %s. got error: %s", m.selectedModule.URL(), err)) } } default: m.logger.Warnf("Unknown button pressed: %s", currentAction) } case key.Matches(msg, m.pagerKeys.Scaffold): m.State = ScaffoldState return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) case key.Matches(msg, m.pagerKeys.Quit): // because we're on the second screen, we need to go back m.State = ListState return m, nil } case buttonbar.ActiveBtnMsg: // Map the index from buttonbar.ActiveBtnMsg to the actual button type if int(msg) >= 0 && int(msg) < len(m.currentPagerButtons) { m.activeButton = m.currentPagerButtons[int(msg)] } } // Handle keyboard and mouse events in the viewport m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } // Update handles all TUI interactions and implements bubbletea.Model.Update. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocritic switch msg := msg.(type) { case tea.WindowSizeMsg: h, v := appStyle.GetFrameSize() m.List.SetSize(msg.Width-h, msg.Height-v) m.width = msg.Width m.height = msg.Height if !m.ready { // Since this program is using the full size of the viewport we // need to wait until we've received the window dimensions before // we can initialize the viewport. The initial dimensions come in // quickly, though asynchronously, which is why we wait for them // here. m.viewport = viewport.New(msg.Width, msg.Height-v-lipgloss.Height(m.footerView())) m.ready = true } else { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - v - lipgloss.Height(m.footerView()) } case scaffoldFinishedMsg: if msg.err != nil { tea.Printf("error scaffolding module: %s", msg.err.Error()) } return m, tea.Quit case rendererErrMsg: m.viewport.SetContent("there was an error rendering markdown: " + msg.err.Error()) // ensure we show the viewport m.State = PagerState } // Hand off the message and model to the appropriate update function for the // appropriate view based on the current state. switch m.State { case ListState: return updateList(msg, m) case PagerState: return updatePager(msg, m) case ScaffoldState: // if we're on the scaffold state, we do nothing and wait for the // scaffoldFinishedMsg message. This prevents further input. return m, nil } return m, nil } type rendererErrMsg struct{ err error } func rendererErrCmd(err error) tea.Cmd { return func() tea.Msg { return rendererErrMsg{err} } } type scaffoldFinishedMsg struct{ err error } // Return a tea.Cmd that will scaffold the given module. func scaffoldModuleCmd(l log.Logger, m Model, svc catalog.CatalogService, module *module.Module) tea.Cmd { //nolint:gocritic return tea.Exec(command.NewScaffold(l, m.terragruntOptions, svc, module), func(err error) tea.Msg { return scaffoldFinishedMsg{err} }) } ================================================ FILE: internal/cli/commands/catalog/tui/view.go ================================================ package tui import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" ) var ( appStyle = lipgloss.NewStyle().Padding(1, 2) //nolint:mnd infoPositionStyle = lipgloss.NewStyle().Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) infoLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1D252")) infoHelp = lipgloss.NewStyle().Padding(2, 0, 0, 2) //nolint:mnd ) // View is the main view, which just calls the appropriate sub-view and returns a string representation of the TUI // based on the application's state. func (m Model) View() string { //nolint:gocritic var s string switch m.State { case ListState: s = m.listView() case PagerState: s = m.pagerView() case ScaffoldState: default: s = "" } return s } func (m Model) listView() string { //nolint:gocritic return m.List.View() } func (m Model) pagerView() string { //nolint:gocritic return lipgloss.JoinVertical(lipgloss.Left, m.viewport.View(), m.footerView()) } func (m Model) footerView() string { //nolint:gocritic var percent float64 = 100 info := infoPositionStyle.Render(fmt.Sprintf("%2.f%%", m.viewport.ScrollPercent()*percent)) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) line = infoLineStyle.Render(line) info = lipgloss.JoinHorizontal(lipgloss.Center, line, info) // button bar and key help pagerKeys := infoHelp.Render(lipgloss.JoinVertical(lipgloss.Left, m.buttonBar.View(), "\n", m.pagerKeys.help.View(m.pagerKeys))) return lipgloss.JoinVertical(lipgloss.Left, info, pagerKeys) } ================================================ FILE: internal/cli/commands/commands.go ================================================ // Package commands represents CLI commands. package commands import ( "context" "fmt" "os" "path/filepath" "slices" "strings" "golang.org/x/sync/errgroup" "github.com/gruntwork-io/go-commons/env" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/providercache" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" awsproviderpatch "github.com/gruntwork-io/terragrunt/internal/cli/commands/aws-provider-patch" "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog" "github.com/gruntwork-io/terragrunt/internal/cli/commands/dag" execcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/exec" "github.com/gruntwork-io/terragrunt/internal/cli/commands/find" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl" helpcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/help" "github.com/gruntwork-io/terragrunt/internal/cli/commands/info" "github.com/gruntwork-io/terragrunt/internal/cli/commands/list" "github.com/gruntwork-io/terragrunt/internal/cli/commands/render" runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" "github.com/gruntwork-io/terragrunt/internal/cli/commands/stack" versioncmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/version" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tips" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/hashicorp/go-version" ) // Command category names. const ( // MainCommandsCategoryName represents primary Terragrunt operations like run, exec. MainCommandsCategoryName = "Main commands" // CatalogCommandsCategoryName represents commands for managing Terragrunt catalogs. CatalogCommandsCategoryName = "Catalog commands" // DiscoveryCommandsCategoryName represents commands for discovering Terragrunt configurations. DiscoveryCommandsCategoryName = "Discovery commands" // ConfigurationCommandsCategoryName represents commands for managing Terragrunt configurations. ConfigurationCommandsCategoryName = "Configuration commands" // ShortcutsCommandsCategoryName represents OpenTofu-specific shortcut commands. ShortcutsCommandsCategoryName = "OpenTofu shortcuts" ) // New returns the set of Terragrunt commands, grouped into categories. // Categories are ordered in increments of 10 for easy insertion of new categories. func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { mainCommands := clihelper.Commands{ runcmd.NewCommand(l, opts), // run stack.NewCommand(l, opts), // stack execcmd.NewCommand(l, opts), // exec backend.NewCommand(l, opts), // backend }.SetCategory( &clihelper.Category{ Name: MainCommandsCategoryName, Order: 10, //nolint: mnd }, ) catalogCommands := clihelper.Commands{ catalog.NewCommand(l, opts), // catalog scaffold.NewCommand(l, opts), // scaffold }.SetCategory( &clihelper.Category{ Name: CatalogCommandsCategoryName, Order: 20, //nolint: mnd }, ) discoveryCommands := clihelper.Commands{ find.NewCommand(l, opts), // find list.NewCommand(l, opts), // list }.SetCategory( &clihelper.Category{ Name: DiscoveryCommandsCategoryName, Order: 30, //nolint: mnd }, ) configurationCommands := clihelper.Commands{ hcl.NewCommand(l, opts), // hcl info.NewCommand(l, opts), // info dag.NewCommand(l, opts), // dag render.NewCommand(l, opts), // render helpcmd.NewCommand(l, opts), // help (hidden) versioncmd.NewCommand(), // version (hidden) awsproviderpatch.NewCommand(l, opts), // aws-provider-patch (hidden) }.SetCategory( &clihelper.Category{ Name: ConfigurationCommandsCategoryName, Order: 40, //nolint: mnd }, ) shortcutsCommands := NewShortcutsCommands(l, opts).SetCategory( &clihelper.Category{ Name: ShortcutsCommandsCategoryName, Order: 50, //nolint: mnd }, ) allCommands := mainCommands. Merge(catalogCommands...). Merge(discoveryCommands...). Merge(configurationCommands...). Merge(shortcutsCommands...) return allCommands } // WrapWithTelemetry wraps CLI command execution with setting of telemetry context and labels, if telemetry is disabled, just runAction the command. func WrapWithTelemetry(l log.Logger, opts *options.TerragruntOptions) func(ctx context.Context, cliCtx *clihelper.Context, action clihelper.ActionFunc) error { return func(ctx context.Context, cliCtx *clihelper.Context, action clihelper.ActionFunc) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, fmt.Sprintf("%s %s", cliCtx.Command.Name, opts.TerraformCommand), map[string]any{ "terraformCommand": opts.TerraformCommand, "args": opts.TerraformCliArgs, "dir": opts.WorkingDir, }, func(childCtx context.Context) error { if err := initialSetup(cliCtx, l, opts); err != nil { return err } if err := runAction(childCtx, cliCtx, l, opts, action); err != nil { opts.Tips.Find(tips.DebuggingDocs).Evaluate(l) return err } return nil }) } } func runAction(ctx context.Context, cliCtx *clihelper.Context, l log.Logger, opts *options.TerragruntOptions, action clihelper.ActionFunc) error { ctx, cancel := context.WithCancel(ctx) defer cancel() errGroup, ctx := errgroup.WithContext(ctx) // Set up automatic provider caching if enabled if !opts.NoAutoProviderCacheDir { if err := setupAutoProviderCacheDir(ctx, l, opts); err != nil { l.Debugf("Auto provider cache dir setup failed: %v", err) } } // Re-enable VT processing after subprocess execution may have reset console mode. // Defense-in-depth on top of RunCommandWithOutput's own save/restore cycle. if !exec.PrepareConsole(l) { l.Formatter().SetDisabledColors(true) } // actionCtx is the context passed to the action, which may be wrapped with hooks actionCtx := ctx // Run provider cache server if opts.ProviderCacheOptions.Enabled { server, err := providercache.InitServer(l, &opts.ProviderCacheOptions, opts.RootWorkingDir) if err != nil { return err } ln, err := server.Listen(ctx) if err != nil { return err } defer ln.Close() //nolint:errcheck actionCtx = tf.ContextWithTerraformCommandHook(ctx, server.TerraformCommandHook) errGroup.Go(func() error { return server.Run(ctx, ln) }) } // Run command action errGroup.Go(func() error { defer cancel() if action != nil { return action(actionCtx, cliCtx) } return nil }) return errGroup.Wait() } const minTofuVersionForAutoProviderCacheDir = "1.10.0" // setupAutoProviderCacheDir configures native provider caching by setting TF_PLUGIN_CACHE_DIR. // // Only works with OpenTofu version >= 1.10. Returns error if conditions aren't met. func setupAutoProviderCacheDir(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { // Set TF_PLUGIN_CACHE_DIR environment variable if opts.Env[tf.EnvNameTFPluginCacheDir] != "" { l.Debugf( "TF_PLUGIN_CACHE_DIR already set to %s, skipping auto provider cache dir", opts.Env[tf.EnvNameTFPluginCacheDir], ) return nil } if opts.TerraformVersion == nil { _, ver, impl, err := run.PopulateTFVersion(ctx, l, opts.WorkingDir, opts.VersionManagerFileName, configbridge.TFRunOptsFromOpts(opts)) if err != nil { return err } opts.TerraformVersion = ver opts.TofuImplementation = impl } terraformVersion := opts.TerraformVersion tfImplementation := opts.TofuImplementation // Check if OpenTofu is being used if tfImplementation != tfimpl.OpenTofu { return errors.Errorf("auto provider cache dir requires OpenTofu, but detected %s", tfImplementation) } // Check OpenTofu version > 1.10 if terraformVersion == nil { return errors.New("cannot determine OpenTofu version") } requiredVersion, err := version.NewVersion(minTofuVersionForAutoProviderCacheDir) if err != nil { return errors.Errorf("failed to parse required version: %w", err) } if terraformVersion.LessThan(requiredVersion) { return errors.Errorf("auto provider cache dir requires OpenTofu version >= 1.10, but found %s", terraformVersion) } // Set up the provider cache directory providerCacheDir := opts.ProviderCacheOptions.Dir if providerCacheDir == "" { cacheDir, err := util.GetCacheDir() if err != nil { return errors.Errorf("failed to get cache directory: %w", err) } providerCacheDir = filepath.Join(cacheDir, "providers") } // Make sure the cache directory is absolute if !filepath.IsAbs(providerCacheDir) { providerCacheDir = filepath.Join(opts.RootWorkingDir, providerCacheDir) } providerCacheDir = filepath.Clean(providerCacheDir) const cacheDirMode = 0755 // Create the cache directory if it doesn't exist if err := os.MkdirAll(providerCacheDir, cacheDirMode); err != nil { return errors.Errorf("failed to create provider cache directory: %w", err) } // Initialize environment variables map if it's nil if opts.Env == nil { opts.Env = make(map[string]string) } opts.Env[tf.EnvNameTFPluginCacheDir] = providerCacheDir l.Debugf("Auto provider cache dir enabled: TF_PLUGIN_CACHE_DIR=%s", providerCacheDir) return nil } // mostly preparing terragrunt options func initialSetup(cliCtx *clihelper.Context, l log.Logger, opts *options.TerragruntOptions) error { // convert the rest flags (intended for terraform) to one dash, e.g. `--input=true` to `-input=true` args := cliCtx.Args().WithoutBuiltinCmdSep().Normalize(clihelper.SingleDashFlag) cmdName := cliCtx.Command.Name if cmdName == runcmd.CommandName { cmdName = args.CommandName() } else { args = append([]string{cmdName}, args...) } // `terraform apply -destroy` is an alias for `terraform destroy`. // It is important to resolve the alias because the `run --all` relies on terraform command to determine the order, for `destroy` command is used the reverse order. if cmdName == tf.CommandNameApply && slices.Contains(args, tf.FlagNameDestroy) { cmdName = tf.CommandNameDestroy args = append([]string{tf.CommandNameDestroy}, args.Tail()...) args = slices.DeleteFunc(args, func(arg string) bool { return arg == tf.FlagNameDestroy }) } // Since Terragrunt and Terraform have the same `-no-color` flag, // if a user specifies `-no-color` for Terragrunt, we should propagate it to Terraform as well. if l.Formatter().DisabledColors() { args = append(args, tf.FlagNameNoColor) } opts.TerraformCommand = cmdName opts.TerraformCliArgs = iacargs.New(args...) opts.Env = env.Parse(os.Environ()) // --- Working Dir if opts.WorkingDir == "" { currentDir, err := os.Getwd() if err != nil { return errors.New(err) } opts.WorkingDir = currentDir } else if !filepath.IsAbs(opts.WorkingDir) { workingDir, err := filepath.Abs(opts.WorkingDir) if err != nil { return errors.New(err) } opts.WorkingDir = workingDir } opts.WorkingDir = filepath.Clean(opts.WorkingDir) l = l.WithField(placeholders.WorkDirKeyName, opts.WorkingDir) opts.RootWorkingDir = opts.WorkingDir if err := l.Formatter().SetBaseDir(opts.RootWorkingDir); err != nil { return err } if opts.Writers.LogShowAbsPaths { l.Formatter().DisableRelativePaths() } // --- Download Dir if opts.DownloadDir == "" { opts.DownloadDir = filepath.Join(opts.WorkingDir, util.TerragruntCacheDir) } else if !filepath.IsAbs(opts.DownloadDir) { opts.DownloadDir = filepath.Join(opts.RootWorkingDir, opts.DownloadDir) } opts.DownloadDir = filepath.Clean(opts.DownloadDir) // --- Terragrunt ConfigPath if opts.TerragruntConfigPath == "" { opts.TerragruntConfigPath = config.GetDefaultConfigPath(opts.WorkingDir) } else if !filepath.IsAbs(opts.TerragruntConfigPath) && (cliCtx.Command.Name == runcmd.CommandName || slices.Contains(tf.CommandNames, cliCtx.Command.Name)) { opts.TerragruntConfigPath = filepath.Join(opts.WorkingDir, opts.TerragruntConfigPath) } opts.TerragruntConfigPath = filepath.Clean(opts.TerragruntConfigPath) if !filepath.IsAbs(opts.TFPath) && strings.Contains(opts.TFPath, string(filepath.Separator)) { opts.TFPath = filepath.Join(opts.WorkingDir, opts.TFPath) } var fileFilterStrings []string excludeFiltersFromFile, err := util.ExcludeFiltersFromFile(opts.WorkingDir, opts.ExcludesFile) if err != nil { return err } fileFilterStrings = append(fileFilterStrings, excludeFiltersFromFile...) // Process filters file if the filters file is not disabled if !opts.NoFiltersFile { filtersFromFile, filtersFromFileErr := util.GetFiltersFromFile(opts.WorkingDir, opts.FiltersFile) if filtersFromFileErr != nil { return filtersFromFileErr } fileFilterStrings = append(fileFilterStrings, filtersFromFile...) } if len(fileFilterStrings) > 0 { parsed, parseErr := filter.ParseFilterQueries(l, fileFilterStrings) if parseErr != nil { return parseErr } opts.Filters = append(opts.Filters, parsed...) } // Deduplicate filters by their string representation seen := make(map[string]struct{}, len(opts.Filters)) deduped := make(filter.Filters, 0, len(opts.Filters)) for _, f := range opts.Filters { key := f.String() if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} deduped = append(deduped, f) } opts.Filters = deduped // --- Terragrunt Version terragruntVersion, err := version.NewVersion(cliCtx.Version) if err != nil { // Malformed Terragrunt version; set the version to 0.0 if terragruntVersion, err = version.NewVersion("0.0"); err != nil { return errors.New(err) } } opts.TerragruntVersion = terragruntVersion // Log the terragrunt version in debug mode. This helps with debugging issues and ensuring a specific version of terragrunt used. l.Debugf("Terragrunt Version: %s", opts.TerragruntVersion) // --- Others if !opts.RunAllAutoApprove { // When running in no-auto-approve mode, set parallelism to 1 so that interactive prompts work. opts.Parallelism = 1 } opts.OriginalTerragruntConfigPath = opts.TerragruntConfigPath opts.OriginalTerraformCommand = opts.TerraformCommand opts.OriginalIAMRoleOptions = opts.IAMRoleOptions if !exec.PrepareConsole(l) { l.Debugf("Virtual terminal processing not available, disabling colors") l.Formatter().SetDisabledColors(true) } return nil } ================================================ FILE: internal/cli/commands/dag/cli.go ================================================ // Package dag implements the dag command to interact with the Directed Acyclic Graph (DAG). // It provides functionality to visualize and analyze dependencies between Terragrunt configurations. package dag import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/dag/graph" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "dag" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Interact with the Directed Acyclic Graph (DAG).", Subcommands: clihelper.Commands{ graph.NewCommand(l, opts), }, Action: clihelper.ShowCommandHelp, } } ================================================ FILE: internal/cli/commands/dag/graph/cli.go ================================================ // Package graph implements the terragrunt dag graph command which generates a visual // representation of the Terragrunt dependency graph in DOT language format. // // Alias for 'list --format=dot --dag --dependencies --external'. package graph import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/commands/list" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "graph" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { sharedFlags := shared.NewQueueFlags(opts, nil) sharedFlags = append(sharedFlags, shared.NewBackendFlags(opts, nil)...) sharedFlags = append(sharedFlags, shared.NewFeatureFlags(opts, nil)...) sharedFlags = append(sharedFlags, shared.NewFilterFlags(l, opts)...) return &clihelper.Command{ Name: CommandName, Usage: "Graph the Directed Acyclic Graph (DAG) in DOT language. Alias for 'list --format=dot --dag --dependencies --external'.", UsageText: "terragrunt dag graph", Flags: sharedFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts) }, } } func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { listOpts := list.NewOptions(opts) listOpts.Format = list.FormatDot listOpts.Mode = list.ModeDAG listOpts.Dependencies = true return list.Run(ctx, l, listOpts) } ================================================ FILE: internal/cli/commands/dag/graph/cli_test.go ================================================ package graph_test import ( "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/dag/graph" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/require" ) // Run a benchmark on runGraphDependencies for all fixtures possible. // This should reveal regression on execution time due to new, changed or removed features. func BenchmarkRunGraphDependencies(b *testing.B) { // Setup b.StopTimer() testDir := "../../../../../test/fixtures" fixtureDirs := []struct { description string workingDir string usePartialParseCache bool }{ {"PartialParseBenchmarkRegressionCaching", "regressions/benchmark-parsing/production/deployment-group-1/webserver/terragrunt.hcl", true}, {"PartialParseBenchmarkRegressionNoCache", "regressions/benchmark-parsing/production/deployment-group-1/webserver/terragrunt.hcl", false}, {"PartialParseBenchmarkRegressionIncludesCaching", "regressions/benchmark-parsing-includes/production/deployment-group-1/webserver/terragrunt.hcl", true}, {"PartialParseBenchmarkRegressionIncludesNoCache", "regressions/benchmark-parsing-includes/production/deployment-group-1/webserver/terragrunt.hcl", false}, } // Run benchmarks for _, fixture := range fixtureDirs { b.Run(fixture.description, func(b *testing.B) { workingDir, err := filepath.Abs(filepath.Join(testDir, fixture.workingDir)) require.NoError(b, err) terragruntOptions, err := options.NewTerragruntOptionsForTest(workingDir) if fixture.usePartialParseCache { terragruntOptions.UsePartialParseConfigCache = true } else { terragruntOptions.UsePartialParseConfigCache = false } require.NoError(b, err) b.ResetTimer() b.StartTimer() err = graph.Run(b.Context(), logger.CreateLogger(), terragruntOptions) b.StopTimer() require.NoError(b, err) }) } } ================================================ FILE: internal/cli/commands/exec/cli.go ================================================ // Package exec provides the ability to execute a command using Terragrunt, // via the `terragrunt exec -- command_name` command. package exec import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "exec" InDownloadDirFlagName = "in-download-dir" ) func NewFlags(l log.Logger, opts *options.TerragruntOptions, cmdOpts *Options, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) sharedFlags := append( clihelper.Flags{ shared.NewConfigFlag(opts, prefix, CommandName), shared.NewDownloadDirFlag(opts, prefix, CommandName), shared.NewTFPathFlag(opts), shared.NewAuthProviderCmdFlag(opts, prefix, CommandName), shared.NewInputsDebugFlag(opts, prefix, CommandName), }, shared.NewIAMAssumeRoleFlags(opts, prefix, CommandName)..., ) return append(sharedFlags, flags.NewFlag(&clihelper.BoolFlag{ Name: InDownloadDirFlagName, EnvVars: tgPrefix.EnvVars(InDownloadDirFlagName), Destination: &cmdOpts.InDownloadDir, Usage: "Run the provided command in the download directory.", }), ) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdOpts := NewOptions() return &clihelper.Command{ Name: CommandName, Usage: "Execute an arbitrary command.", UsageText: "terragrunt exec [options] -- ", Description: "Execute a command using Terragrunt.", Examples: []string{ "# Utilize the AWS CLI.\nterragrunt exec -- aws s3 ls", "# Inspect `main.tf` file of module for Unit\nterragrunt exec --in-download-dir -- cat main.tf", }, Flags: NewFlags(l, opts, cmdOpts, nil), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { tgArgs, cmdArgs := cliCtx.Args().Split(clihelper.BuiltinCmdSep) // Use unspecified arguments from the terragrunt command if the user // specified the target command without `--`, e.g. `terragrunt exec ls`. if len(cmdArgs) == 0 { cmdArgs = tgArgs } if len(cmdArgs) == 0 { return clihelper.ShowCommandHelp(ctx, cliCtx) } return Run(ctx, l, opts, cmdOpts, cmdArgs) }, } } ================================================ FILE: internal/cli/commands/exec/exec.go ================================================ package exec import ( "context" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cmdOpts *Options, args clihelper.Args) error { prepared, err := prepare.PrepareConfig(ctx, l, opts) if err != nil { return err } r := report.NewReport() // Download source updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) if err != nil { return err } runCfg := prepared.Cfg.ToRunConfig(l) // Generate config if err := prepare.PrepareGenerate(l, updatedOpts, runCfg); err != nil { return err } if cmdOpts.InDownloadDir { // Run terraform init if err := prepare.PrepareInit(ctx, l, opts, updatedOpts, runCfg, r); err != nil { return err } } else { // Just set inputs as env vars, skip init updatedOpts.AutoInit = false if err := prepare.PrepareInputsAsEnvVars(l, updatedOpts, runCfg); err != nil { return err } } return runTargetCommand(ctx, l, updatedOpts, runCfg, r, cmdOpts, args) } func runTargetCommand( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cfg *runcfg.RunConfig, r *report.Report, cmdOpts *Options, args clihelper.Args, ) error { var ( command = args.CommandName() cmdArgs = args.Tail() dir = opts.WorkingDir ) if !cmdOpts.InDownloadDir { dir = opts.RootWorkingDir } runOpts := configbridge.NewRunOptions(opts) return run.RunActionWithHooks(ctx, l, command, runOpts, cfg, r, func(ctx context.Context) error { _, err := shell.RunCommandWithOutput(ctx, l, configbridge.ShellRunOptsFromOpts(opts), dir, false, false, command, cmdArgs...) if err != nil { return errors.Errorf("failed to run command in directory %s: %w", dir, err) } return nil }) } ================================================ FILE: internal/cli/commands/exec/options.go ================================================ package exec type Options struct { // InDownloadDir determines whether the command should execute in the download directory // rather than the working directory. InDownloadDir bool } func NewOptions() *Options { return &Options{} } ================================================ FILE: internal/cli/commands/find/cli.go ================================================ // Package find provides the ability to find Terragrunt configurations in your codebase // via the `terragrunt find` command. package find import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "find" CommandAlias = "fd" FormatFlagName = "format" JSONFlagName = "json" JSONFlagAlias = "j" DAGFlagName = "dag" HiddenFlagName = "hidden" NoHiddenFlagName = "no-hidden" Dependencies = "dependencies" External = "external" Exclude = "exclude" Include = "include" Reading = "reading" QueueConstructAsFlagName = "queue-construct-as" QueueConstructAsFlagAlias = "as" ) func NewFlags(l log.Logger, opts *Options, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) flags := clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: FormatFlagName, EnvVars: tgPrefix.EnvVars(FormatFlagName), Destination: &opts.Format, Usage: "Output format for find results. Valid values: text, json.", DefaultText: FormatText, }), flags.NewFlag(&clihelper.BoolFlag{ Name: JSONFlagName, EnvVars: tgPrefix.EnvVars(JSONFlagName), Aliases: []string{JSONFlagAlias}, Destination: &opts.JSON, Usage: "Output in JSON format (equivalent to --format=json).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: DAGFlagName, EnvVars: tgPrefix.EnvVars(DAGFlagName), Destination: &opts.DAG, Usage: "Use DAG mode to sort and group output.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoHiddenFlagName, EnvVars: tgPrefix.EnvVars(NoHiddenFlagName), Destination: &opts.NoHidden, Usage: "Exclude hidden directories from find results.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: HiddenFlagName, EnvVars: tgPrefix.EnvVars(HiddenFlagName), Usage: "Include hidden directories in find results.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { if err := opts.StrictControls.FilterByNames(controls.DeprecatedHiddenFlag).Evaluate(ctx); err != nil { return err } } return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: Dependencies, EnvVars: tgPrefix.EnvVars(Dependencies), Destination: &opts.Dependencies, Usage: "Include dependencies in the results (only when using --format=json).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: Exclude, EnvVars: tgPrefix.EnvVars(Exclude), Destination: &opts.Exclude, Usage: "Display exclude configurations in the results (only when using --format=json).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: Include, EnvVars: tgPrefix.EnvVars(Include), Destination: &opts.Include, Usage: "Display include configurations in the results (only when using --format=json).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: Reading, EnvVars: tgPrefix.EnvVars(Reading), Destination: &opts.Reading, Usage: "Include the list of files that are read by components in the results (only when using --format=json).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: External, EnvVars: tgPrefix.EnvVars(External), Hidden: true, Usage: "Discover external dependencies from initial results, and add them to top-level results (implies discovery of dependencies).", Action: func(_ context.Context, _ *clihelper.Context, value bool) error { if !value { return nil } pathExpr, err := filter.NewPathFilter("./**") if err != nil { return err } graphExpr := filter.NewGraphExpression(pathExpr).WithDependencies() opts.Filters = append(opts.Filters, filter.NewFilter(graphExpr, graphExpr.String())) return nil }, }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: QueueConstructAsFlagName, EnvVars: tgPrefix.EnvVars(QueueConstructAsFlagName), Destination: &opts.QueueConstructAs, Usage: "Construct the queue as if a specific command was run.", Aliases: []string{QueueConstructAsFlagAlias}, }), } return append(flags, shared.NewFilterFlags(l, opts.TerragruntOptions)...) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdOpts := NewOptions(opts) // Base flags for find plus backend/feature flags flags := NewFlags(l, cmdOpts, nil) flags = append(flags, shared.NewBackendFlags(opts, nil)...) flags = append(flags, shared.NewFeatureFlags(opts, nil)...) return &clihelper.Command{ Name: CommandName, Aliases: []string{CommandAlias}, Usage: "Find relevant Terragrunt configurations.", Flags: flags, Before: func(_ context.Context, _ *clihelper.Context) error { if cmdOpts.JSON { cmdOpts.Format = FormatJSON } if cmdOpts.DAG { cmdOpts.Mode = ModeDAG } // Requesting a specific command to be used for queue construction // implies DAG mode. if cmdOpts.QueueConstructAs != "" { cmdOpts.Mode = ModeDAG } if err := cmdOpts.Validate(); err != nil { return clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } return nil }, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, cmdOpts) }, } } ================================================ FILE: internal/cli/commands/find/find.go ================================================ package find import ( "context" "encoding/json" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/os/stdout" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/mgutz/ansi" ) // Run runs the find command. func Run(ctx context.Context, l log.Logger, opts *Options) error { d, err := discovery.NewForDiscoveryCommand(l, &discovery.DiscoveryCommandOptions{ WorkingDir: opts.WorkingDir, QueueConstructAs: opts.QueueConstructAs, NoHidden: opts.NoHidden, WithRequiresParse: opts.Dependencies || opts.Mode == ModeDAG, WithRelationships: opts.Dependencies || opts.Mode == ModeDAG, Exclude: opts.Exclude, Include: opts.Include, Reading: opts.Reading, Filters: opts.Filters, Experiments: opts.Experiments, }) if err != nil { return errors.New(err) } // We do worktree generation here instead of in the discovery constructor // so that we can defer cleanup in the same context. gitFilters := opts.Filters.UniqueGitFilters() worktrees, worktreeErr := worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if worktreeErr != nil { return errors.Errorf("failed to create worktrees: %w", worktreeErr) } defer func() { cleanupErr := worktrees.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() d = d.WithWorktrees(worktrees) var ( components component.Components discoverErr error ) telemetryErr := telemetry.TelemeterFromContext(ctx).Collect(ctx, "find_discover", map[string]any{ "working_dir": opts.WorkingDir, "no_hidden": opts.NoHidden, "dependencies": opts.Dependencies, "mode": opts.Mode, "exclude": opts.Exclude, }, func(ctx context.Context) error { components, discoverErr = d.Discover(ctx, l, opts.TerragruntOptions) return discoverErr }) if telemetryErr != nil { l.Debugf("Errors encountered while discovering components:\n%s", telemetryErr) } switch opts.Mode { case ModeNormal: components = components.Sort() case ModeDAG: err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "find_mode_dag", map[string]any{ "working_dir": opts.WorkingDir, "config_count": len(components), }, func(ctx context.Context) error { q, queueErr := queue.NewQueue(components) if queueErr != nil { return queueErr } components = q.Components() return nil }) if err != nil { return errors.New(err) } default: // This should never happen, because of validation in the command. // If it happens, we want to throw so we can fix the validation. return errors.New("invalid mode: " + opts.Mode) } var foundComponents FoundComponents err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "find_discovered_to_found", map[string]any{ "working_dir": opts.WorkingDir, "config_count": len(components), }, func(ctx context.Context) error { var convErr error foundComponents, convErr = discoveredToFound(components, opts) return convErr }) if err != nil { return errors.New(err) } switch opts.Format { case FormatText: return outputText(l, opts, foundComponents) case FormatJSON: return outputJSON(opts, foundComponents) default: // This should never happen, because of validation in the command. // If it happens, we want to throw so we can fix the validation. return errors.New("invalid format: " + opts.Format) } } type FoundComponents []*FoundComponent type FoundComponent struct { Type component.Kind `json:"type"` Path string `json:"path"` Exclude *config.ExcludeConfig `json:"exclude,omitempty"` Include map[string]string `json:"include,omitempty"` Dependencies []string `json:"dependencies,omitempty"` Reading []string `json:"reading,omitempty"` } func discoveredToFound(components component.Components, opts *Options) (FoundComponents, error) { foundComponents := make(FoundComponents, 0, len(components)) errs := []error{} for _, c := range components { if opts.QueueConstructAs != "" { if unit, ok := c.(*component.Unit); ok { if cfg := unit.Config(); cfg != nil && cfg.Exclude != nil { if cfg.Exclude.IsActionListed(opts.QueueConstructAs) { continue } } } } var ( relPath string err error ) if c.DiscoveryContext() != nil && c.DiscoveryContext().WorkingDir != "" { relPath, err = filepath.Rel(c.DiscoveryContext().WorkingDir, c.Path()) } else { relPath, err = filepath.Rel(opts.WorkingDir, c.Path()) } if err != nil { errs = append(errs, errors.New(err)) continue } foundComponent := &FoundComponent{ Type: c.Kind(), Path: relPath, } if opts.Exclude { if unit, ok := c.(*component.Unit); ok { if cfg := unit.Config(); cfg != nil && cfg.Exclude != nil { foundComponent.Exclude = cfg.Exclude.Clone() } } } if opts.Include { if unit, ok := c.(*component.Unit); ok { if cfg := unit.Config(); cfg != nil && cfg.ProcessedIncludes != nil { foundComponent.Include = make(map[string]string, len(cfg.ProcessedIncludes)) for _, v := range cfg.ProcessedIncludes { foundComponent.Include[v.Name], err = util.GetPathRelativeTo(v.Path, opts.RootWorkingDir) if err != nil { errs = append(errs, errors.New(err)) } } } } } if opts.Reading && len(c.Reading()) > 0 { foundComponent.Reading = make([]string, len(c.Reading())) for i, reading := range c.Reading() { var relReadingPath string if c.DiscoveryContext() != nil && c.DiscoveryContext().WorkingDir != "" { relReadingPath, err = filepath.Rel(c.DiscoveryContext().WorkingDir, reading) } else { relReadingPath, err = filepath.Rel(opts.WorkingDir, reading) } if err != nil { errs = append(errs, errors.New(err)) continue } foundComponent.Reading[i] = relReadingPath } } if opts.Dependencies && len(c.Dependencies()) > 0 { foundComponent.Dependencies = make([]string, len(c.Dependencies())) for i, dep := range c.Dependencies() { var relDepPath string if dep.DiscoveryContext() != nil && dep.DiscoveryContext().WorkingDir != "" { relDepPath, err = filepath.Rel(dep.DiscoveryContext().WorkingDir, dep.Path()) } else { relDepPath, err = filepath.Rel(opts.WorkingDir, dep.Path()) } if err != nil { errs = append(errs, errors.New(err)) continue } foundComponent.Dependencies[i] = relDepPath } } foundComponents = append(foundComponents, foundComponent) } return foundComponents, errors.Join(errs...) } // outputJSON outputs the discovered components in JSON format. func outputJSON(opts *Options, components FoundComponents) error { jsonBytes, err := json.MarshalIndent(components, "", " ") if err != nil { return errors.New(err) } _, err = opts.Writers.Writer.Write(append(jsonBytes, []byte("\n")...)) if err != nil { return errors.New(err) } return nil } // Colorizer is a colorizer for the discovered components. type Colorizer struct { unitColorizer func(string) string stackColorizer func(string) string pathColorizer func(string) string } // NewColorizer creates a new Colorizer. func NewColorizer(shouldColor bool) *Colorizer { if !shouldColor { return &Colorizer{ unitColorizer: func(s string) string { return s }, stackColorizer: func(s string) string { return s }, pathColorizer: func(s string) string { return s }, } } return &Colorizer{ unitColorizer: ansi.ColorFunc("blue+bh"), stackColorizer: ansi.ColorFunc("green+bh"), pathColorizer: ansi.ColorFunc("white+d"), } } func (c *Colorizer) Colorize(foundComponent *FoundComponent) string { path := foundComponent.Path // Get the directory and base name using filepath dir, base := filepath.Split(path) if dir == "" { // No directory part, color the whole path switch foundComponent.Type { case component.UnitKind: return c.unitColorizer(path) case component.StackKind: return c.stackColorizer(path) default: return path } } // Color the components differently coloredPath := c.pathColorizer(dir) switch foundComponent.Type { case component.UnitKind: return coloredPath + c.unitColorizer(base) case component.StackKind: return coloredPath + c.stackColorizer(base) default: return path } } // outputText outputs the discovered components in text format. func outputText(l log.Logger, opts *Options, components FoundComponents) error { var buf strings.Builder colorizer := NewColorizer(shouldColor(l)) for _, c := range components { buf.WriteString(colorizer.Colorize(c) + "\n") } _, err := opts.Writers.Writer.Write([]byte(buf.String())) return errors.New(err) } // shouldColor returns true if the output should be colored. func shouldColor(l log.Logger) bool { return !l.Formatter().DisabledColors() && !stdout.IsRedirected() } ================================================ FILE: internal/cli/commands/find/find_test.go ================================================ package find_test import ( "encoding/json" "io" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/find" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRun(t *testing.T) { t.Parallel() tests := []struct { setup func(t *testing.T) string validate func(t *testing.T, output string, expectedPaths []string) name string format string mode string expectedPaths []string noHidden bool dependencies bool external bool reading bool }{ { name: "basic discovery", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "stack1", ".hidden/unit3", "nested/unit4", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "stack1/terragrunt.stack.hcl": "", ".hidden/unit3/terragrunt.hcl": "", "nested/unit4/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } return tmpDir }, expectedPaths: []string{"unit1", "unit2", "nested/unit4", "stack1"}, format: "text", mode: "normal", noHidden: true, dependencies: false, external: false, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Split output into lines and trim whitespace lines := strings.Split(strings.TrimSpace(output), "\n") // Verify we have the expected number of lines assert.Len(t, lines, len(expectedPaths)) // Convert expected paths to use OS-specific path separators osExpectedPaths := make([]string, 0, len(expectedPaths)) for _, path := range expectedPaths { osExpectedPaths = append(osExpectedPaths, filepath.FromSlash(path)) } // Convert actual paths to use OS-specific path separators osPaths := make([]string, 0, len(lines)) for _, line := range lines { osPaths = append(osPaths, filepath.FromSlash(strings.TrimSpace(line))) } // Verify all expected paths are present assert.ElementsMatch(t, osExpectedPaths, osPaths) }, }, { name: "json output format", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "stack1", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "stack1/terragrunt.stack.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } return tmpDir }, expectedPaths: []string{"unit1", "unit2", "stack1"}, format: "json", mode: "normal", dependencies: false, external: false, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Verify the output is valid JSON var configs find.FoundComponents err := json.Unmarshal([]byte(output), &configs) require.NoError(t, err) // Verify we have the expected number of configs assert.Len(t, configs, len(expectedPaths)) // Convert expected paths to use OS-specific path separators osExpectedPaths := make([]string, 0, len(expectedPaths)) for _, path := range expectedPaths { osExpectedPaths = append(osExpectedPaths, filepath.FromSlash(path)) } // Extract paths and convert to OS-specific separators paths := make([]string, 0, len(configs)) for _, config := range configs { paths = append(paths, filepath.FromSlash(config.Path)) } // Verify all expected paths are present assert.ElementsMatch(t, osExpectedPaths, paths) // Verify each config has a valid type for _, config := range configs { assert.NotEmpty(t, config.Type) assert.True(t, config.Type == component.UnitKind || config.Type == component.StackKind) } }, }, { name: "hidden discovery", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "stack1", ".hidden/unit3", "nested/unit4", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "stack1/terragrunt.stack.hcl": "", ".hidden/unit3/terragrunt.hcl": "", "nested/unit4/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } return tmpDir }, expectedPaths: []string{"unit1", "unit2", "nested/unit4", "stack1", ".hidden/unit3"}, format: "text", mode: "normal", dependencies: false, external: false, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Split output into lines and trim whitespace lines := strings.Split(strings.TrimSpace(output), "\n") // Verify we have the expected number of lines assert.Len(t, lines, len(expectedPaths)) // Convert expected paths to use OS-specific path separators osExpectedPaths := make([]string, 0, len(expectedPaths)) for _, path := range expectedPaths { osExpectedPaths = append(osExpectedPaths, filepath.FromSlash(path)) } // Convert actual paths to use OS-specific path separators osPaths := make([]string, 0, len(lines)) for _, line := range lines { osPaths = append(osPaths, filepath.FromSlash(strings.TrimSpace(line))) } // Verify all expected paths are present assert.ElementsMatch(t, osExpectedPaths, osPaths) }, }, { name: "dag sorting - simple dependencies", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure with dependencies: // unit2 -> unit1 // unit3 -> unit2 testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" }`, "unit3/terragrunt.hcl": ` dependency "unit2" { config_path = "../unit2" }`, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } return tmpDir }, expectedPaths: []string{"unit1", "unit2", "unit3"}, format: "text", mode: "dag", dependencies: true, external: false, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Split output into lines and trim whitespace lines := strings.Split(strings.TrimSpace(output), "\n") // Verify we have the expected number of lines assert.Len(t, lines, len(expectedPaths)) // Convert paths to use OS-specific separators osPaths := make([]string, 0, len(lines)) for _, line := range lines { osPaths = append(osPaths, filepath.FromSlash(strings.TrimSpace(line))) } // Convert expected paths to use OS-specific separators osExpectedPaths := make([]string, 0, len(expectedPaths)) for _, path := range expectedPaths { osExpectedPaths = append(osExpectedPaths, filepath.FromSlash(path)) } // For DAG sorting, order matters - verify exact order assert.Equal(t, osExpectedPaths, osPaths) }, }, { name: "dag sorting - json output with dependencies", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure with dependencies testDirs := []string{ "A", "B", "C", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ "A/terragrunt.hcl": "", "B/terragrunt.hcl": ` dependency "A" { config_path = "../A" }`, "C/terragrunt.hcl": ` dependency "B" { config_path = "../B" }`, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } return tmpDir }, expectedPaths: []string{"A", "B", "C"}, format: "json", mode: "dag", dependencies: true, external: false, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Verify the output is valid JSON var configs []find.FoundComponent err := json.Unmarshal([]byte(output), &configs) require.NoError(t, err) // Verify we have the expected number of configs assert.Len(t, configs, len(expectedPaths)) // Extract paths and verify order paths := make([]string, 0, len(configs)) for _, config := range configs { paths = append(paths, filepath.FromSlash(config.Path)) } // Convert expected paths to use OS-specific separators osExpectedPaths := make([]string, 0, len(expectedPaths)) for _, path := range expectedPaths { osExpectedPaths = append(osExpectedPaths, filepath.FromSlash(path)) } assert.Equal(t, osExpectedPaths, paths) // Verify dependencies are correctly represented in JSON assert.Empty(t, configs[0].Dependencies, "A should have no dependencies") assert.Equal(t, []string{"A"}, configs[1].Dependencies, "B should depend on A") assert.Equal(t, []string{"B"}, configs[2].Dependencies, "C should depend on B") }, }, { name: "invalid format", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) return tmpDir }, format: "invalid", validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() assert.Empty(t, output) }, }, { name: "invalid sort", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) return tmpDir }, mode: "invalid", validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() assert.Empty(t, output) }, }, { name: "reading flag with json output", setup: func(t *testing.T) string { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) // Create shared files that will be read sharedHCL := filepath.Join(tmpDir, "shared.hcl") sharedTFVars := filepath.Join(tmpDir, "shared.tfvars") require.NoError(t, os.WriteFile(sharedHCL, []byte(` locals { common_value = "test" } `), 0644)) require.NoError(t, os.WriteFile(sharedTFVars, []byte(` test_var = "value" `), 0644)) // Create terragrunt config that reads both files terragruntConfig := filepath.Join(appDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(terragruntConfig, []byte(` locals { shared_config = read_terragrunt_config("../shared.hcl") tfvars = read_tfvars_file("../shared.tfvars") } `), 0644)) return tmpDir }, expectedPaths: []string{"app"}, format: "json", mode: "normal", reading: true, validate: func(t *testing.T, output string, expectedPaths []string) { t.Helper() // Verify the output is valid JSON var configs find.FoundComponents err := json.Unmarshal([]byte(output), &configs) require.NoError(t, err) // Verify we have one config require.Len(t, configs, 1) // Verify the component has the Reading field populated appConfig := configs[0] require.NotNil(t, appConfig.Reading, "Reading field should be populated") require.NotEmpty(t, appConfig.Reading, "Reading field should contain files") // Verify Reading field contains the shared files readingPaths := appConfig.Reading assert.Len(t, readingPaths, 2, "should have read 2 files") // Convert to map for easier checking readingMap := make(map[string]bool) for _, path := range readingPaths { readingMap[filepath.FromSlash(path)] = true } // Check that shared files are in the reading list assert.True(t, readingMap["shared.hcl"], "should contain shared.hcl") assert.True(t, readingMap["shared.tfvars"], "should contain shared.tfvars") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Setup test directory tmpDir := tt.setup(t) tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) // Create options opts := find.NewOptions(tgOpts) opts.Format = tt.format opts.NoHidden = tt.noHidden opts.Mode = tt.mode opts.Dependencies = tt.dependencies opts.Reading = tt.reading // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w err = find.Run(t.Context(), l, opts) if tt.format == "invalid" || tt.mode == "invalid" { require.Error(t, err) return } require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Validate the output tt.validate(t, string(output), tt.expectedPaths) }) } } func TestColorizer(t *testing.T) { t.Parallel() colorizer := find.NewColorizer(true) tests := []struct { name string config *find.FoundComponent // We can't test exact ANSI codes as they might vary by environment, // so we'll test that different types result in different outputs shouldBeDifferent []component.Kind }{ { name: "unit config", config: &find.FoundComponent{ Type: component.UnitKind, Path: "path/to/unit", }, shouldBeDifferent: []component.Kind{component.StackKind}, }, { name: "stack config", config: &find.FoundComponent{ Type: component.StackKind, Path: "path/to/stack", }, shouldBeDifferent: []component.Kind{component.UnitKind}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := colorizer.Colorize(tt.config) assert.NotEmpty(t, result) // Test that different types produce different colorized outputs for _, diffType := range tt.shouldBeDifferent { diffConfig := &find.FoundComponent{ Type: diffType, Path: tt.config.Path, } diffResult := colorizer.Colorize(diffConfig) assert.NotEqual(t, result, diffResult) } }) } } ================================================ FILE: internal/cli/commands/find/options.go ================================================ package find import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( // FormatText outputs the discovered components in text format. FormatText = "text" // FormatJSON outputs the discovered components in JSON format. FormatJSON = "json" // ModeNormal is the default mode for the find command. ModeNormal = "normal" // ModeDAG is the mode for the find command that sorts and groups output in DAG order. ModeDAG = "dag" ) type Options struct { *options.TerragruntOptions // Format determines the format of the output. Format string // Mode determines the mode of the find command. Mode string // QueueConstructAs constructs the queue as if a particular command was run. QueueConstructAs string // JSON determines if the output should be in JSON format. // Alias for --format=json. JSON bool // DAG determines if the output should be in DAG mode. DAG bool // NoHidden determines if hidden directories should be excluded from the output. NoHidden bool // Dependencies determines if dependencies should be included in the output. Dependencies bool // Exclude determines if exclude components should be included in the output. Exclude bool // Include determines if Include components should be included in the output. Include bool // Reading determines if the list of files that are read by components should be included in the output. Reading bool } func NewOptions(opts *options.TerragruntOptions) *Options { return &Options{ TerragruntOptions: opts, Format: FormatText, Mode: ModeNormal, } } func (o *Options) Validate() error { errs := []error{} if err := o.validateFormat(); err != nil { errs = append(errs, err) } if err := o.validateMode(); err != nil { errs = append(errs, err) } if len(errs) > 0 { return errors.New(errors.Join(errs...)) } return nil } func (o *Options) validateFormat() error { switch o.Format { case FormatText: return nil case FormatJSON: return nil default: return errors.New("invalid format: " + o.Format) } } func (o *Options) validateMode() error { switch o.Mode { case ModeNormal: return nil case ModeDAG: return nil default: return errors.New("invalid mode: " + o.Mode) } } ================================================ FILE: internal/cli/commands/hcl/cli.go ================================================ // Package hcl provides commands for formatting and validating HCL configurations. package hcl import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/validate" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const CommandName = "hcl" func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Interact with HCL files.", Description: "Interact with Terragrunt files written in HashiCorp Configuration Language (HCL).", Subcommands: clihelper.Commands{ format.NewCommand(l, opts), validate.NewCommand(l, opts), }, Action: clihelper.ShowCommandHelp, } } ================================================ FILE: internal/cli/commands/hcl/format/cli.go ================================================ package format import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "format" CommandNameAlias = "fmt" FileFlagName = "file" ExcludeDirFlagName = "exclude-dir" CheckFlagName = "check" DiffFlagName = "diff" StdinFlagName = "stdin" ) func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName) flagSet := clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: FileFlagName, EnvVars: tgPrefix.EnvVars(FileFlagName), Destination: &opts.HclFile, Usage: "The path to a single HCL file that the command should run on.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclfmt-file"), terragruntPrefixControl), // `TG_HCLFMT_FILE` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("hclfmt-file"), terragruntPrefixControl), // `TERRAGRUNT_HCLFMT_FILE` ), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: ExcludeDirFlagName, EnvVars: tgPrefix.EnvVars(ExcludeDirFlagName), Destination: &opts.HclExclude, Usage: "Skip HCL formatting in given directories.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclfmt-exclude-dir"), terragruntPrefixControl), // `TG_HCLFMT_EXCLUDE_DIR` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("hclfmt-exclude-dir"), terragruntPrefixControl), // `TERRAGRUNT_EXCLUDE_DIR` ), flags.NewFlag(&clihelper.BoolFlag{ Name: CheckFlagName, EnvVars: tgPrefix.EnvVars(CheckFlagName), Destination: &opts.Check, Usage: "Return a status code of zero when all files are formatted correctly, and a status code of one when they aren't.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclfmt-check"), terragruntPrefixControl), // `TG_HCLFMT_CHECK` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("check"), terragruntPrefixControl), // `TERRAGRUNT_CHECK` ), flags.NewFlag(&clihelper.BoolFlag{ Name: DiffFlagName, EnvVars: tgPrefix.EnvVars(DiffFlagName), Destination: &opts.Diff, Usage: "Print diff between original and modified file versions.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclfmt-diff"), terragruntPrefixControl), // `TG_HCLFMT_DIFF` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("diff"), terragruntPrefixControl), // `TERRAGRUNT_DIFF` ), flags.NewFlag(&clihelper.BoolFlag{ Name: StdinFlagName, EnvVars: tgPrefix.EnvVars(StdinFlagName), Destination: &opts.HclFromStdin, Usage: "Format HCL from stdin and print result to stdout.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclfmt-stdin"), terragruntPrefixControl), // `TG_HCLFMT_STDIN` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("hclfmt-stdin"), terragruntPrefixControl), // `TERRAGRUNT_HCLFMT_STDIN` ), } flagSet = flagSet.Add(shared.NewQueueFlags(opts, nil)...) flagSet = flagSet.Add(shared.NewFilterFlags(l, opts)...) flagSet = flagSet.Add(shared.NewParallelismFlag(opts)) return flagSet } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmd := &clihelper.Command{ Name: CommandName, Aliases: []string{CommandNameAlias}, Usage: "Recursively find HashiCorp Configuration Language (HCL) files and rewrite them into a canonical format.", Flags: NewFlags(l, opts, nil), Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/hcl/format/errors.go ================================================ package format import "fmt" // FileNeedsFormattingError is an error that is returned when a file needs formatting. type FileNeedsFormattingError struct { Path string } func (e FileNeedsFormattingError) Error() string { return fmt.Sprintf("File '%s' needs formatting", e.Path) } ================================================ FILE: internal/cli/commands/hcl/format/format.go ================================================ // Package format recursively looks for hcl files in the directory tree starting at workingDir, and formats them // based on the language style guides provided by Hashicorp. This is done using the official hcl2 library. package format import ( "bufio" "bytes" "context" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/writer" "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/options" ) var excludePaths = []string{ util.TerragruntCacheDir, util.DefaultBoilerplateDir, config.StackDir, } func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { workingDir := opts.WorkingDir targetFile := opts.HclFile stdIn := opts.HclFromStdin if stdIn { if targetFile != "" { return errors.Errorf("both stdin and path flags are specified") } return formatFromStdin(l, opts) } if targetFile != "" { if !filepath.IsAbs(targetFile) { targetFile = filepath.Join(workingDir, targetFile) } l.Debugf("Formatting hcl file at: %s.", targetFile) return formatTgHCL(ctx, l, opts, targetFile) } var ( filters filter.Filters err error ) filters = opts.Filters // We use lightweight discovery here instead of the full discovery used by // the discovery package because we want to find non-comps like includes. files := []string{} err = filepath.WalkDir(workingDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } basename := filepath.Base(path) if slices.Contains(excludePaths, basename) { l.Debugf("%s directory ignored by default", path) return filepath.SkipDir } if slices.Contains(opts.HclExclude, basename) { l.Debugf("%s directory ignored due to the %s flag", path, ExcludeDirFlagName) return filepath.SkipDir } if d.IsDir() { return nil } if !strings.HasSuffix(path, ".hcl") { return nil } files = append(files, path) return nil }) if err != nil { return errors.New(err) } var components component.Components components, err = filters.EvaluateOnFiles(l, files, workingDir) if err != nil { return errors.New(err) } g, gctx := errgroup.WithContext(ctx) limit := opts.Parallelism if limit == options.DefaultParallelism { limit = runtime.NumCPU() } g.SetLimit(limit) // Pre-allocate the errs slice with max possible length // so we don't need to hold a lock to append to it. errs := make([]error, len(components)) for i, c := range components { g.Go(func() error { err := formatTgHCL(gctx, l, opts, c.Path()) if err != nil { errs[i] = err } return nil }) } _ = g.Wait() return errors.Join(errs...) } func formatFromStdin(l log.Logger, opts *options.TerragruntOptions) error { contents, err := io.ReadAll(os.Stdin) if err != nil { l.Errorf("Error reading from stdin: %s", err) return fmt.Errorf("error reading from stdin: %w", err) } if err = checkErrors(l, l.Formatter().DisabledColors(), contents, "stdin"); err != nil { l.Errorf("Error parsing hcl from stdin") return fmt.Errorf("error parsing hcl from stdin: %w", err) } newContents := hclwrite.Format(contents) buf := bufio.NewWriter(opts.Writers.Writer) if _, err = buf.Write(newContents); err != nil { l.Errorf("Failed to write to stdout") return fmt.Errorf("failed to write to stdout: %w", err) } if err = buf.Flush(); err != nil { l.Errorf("Failed to flush to stdout") return fmt.Errorf("failed to flush to stdout: %w", err) } return nil } // formatTgHCL uses the hcl2 library to format the hcl file. This will attempt to parse the HCL file first to // ensure that there are no syntax errors, before attempting to format it. func formatTgHCL(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, tgHclFile string) error { l.Debugf("Formatting %s", tgHclFile) info, err := os.Stat(tgHclFile) if err != nil { l.Errorf("Error retrieving file info of %s", tgHclFile) return errors.Errorf("failed to get file info for %s: %w", tgHclFile, err) } contents, err := os.ReadFile(tgHclFile) if err != nil { l.Errorf("Error reading %s", tgHclFile) return errors.Errorf("failed to read %s: %w", tgHclFile, err) } err = checkErrors(l, l.Formatter().DisabledColors(), contents, tgHclFile) if err != nil { l.Errorf("Error parsing %s", tgHclFile) return err } newContents := hclwrite.Format(contents) fileUpdated := !bytes.Equal(newContents, contents) if opts.Diff && fileUpdated { diff, err := bytesDiff(ctx, l, contents, newContents, tgHclFile) if err != nil { l.Errorf("Failed to generate diff for %s", tgHclFile) return err } _, err = fmt.Fprintf(opts.Writers.Writer, "%s\n", diff) if err != nil { l.Errorf("Failed to print diff for %s", tgHclFile) return err } } if opts.Check && fileUpdated { return &FileNeedsFormattingError{Path: tgHclFile} } if fileUpdated { l.Infof("%s was updated", tgHclFile) return os.WriteFile(tgHclFile, newContents, info.Mode()) } return nil } // checkErrors takes in the contents of a hcl file and looks for syntax errors. func checkErrors(l log.Logger, disableColor bool, contents []byte, tgHclFile string) error { parser := hclparse.NewParser() _, diags := parser.ParseHCL(contents, tgHclFile) writer := writer.New(writer.WithLogger(l), writer.WithDefaultLevel(log.ErrorLevel)) diagWriter := parser.GetDiagnosticsWriter(writer, disableColor) err := diagWriter.WriteDiagnostics(diags) if err != nil { return errors.New(err) } if diags.HasErrors() { return diags } return nil } // bytesDiff uses GNU diff to display the differences between the contents of HCL file before and after formatting func bytesDiff(ctx context.Context, l log.Logger, b1, b2 []byte, path string) ([]byte, error) { f1, err := os.CreateTemp("", "") if err != nil { return nil, err } defer func() { if err = f1.Close(); err != nil { l.Warnf("Failed to close file %s %v", f1.Name(), err) } if err = os.Remove(f1.Name()); err != nil { l.Warnf("Failed to remove file %s %v", f1.Name(), err) } }() f2, err := os.CreateTemp("", "") if err != nil { return nil, err } defer func() { if err = f2.Close(); err != nil { l.Warnf("Failed to close file %s %v", f2.Name(), err) } if err = os.Remove(f2.Name()); err != nil { l.Warnf("Failed to remove file %s %v", f2.Name(), err) } }() if _, err = f1.Write(b1); err != nil { return nil, err } if _, err = f2.Write(b2); err != nil { return nil, err } diffPath, err := exec.LookPath("diff") if err != nil { return nil, fmt.Errorf("failed to find diff command in PATH: %w", err) } cmd := exec.CommandContext( ctx, diffPath, "--label="+filepath.Join("old", path), "--label="+filepath.Join("new/", path), "-u", f1.Name(), f2.Name(), ) cmd.Cancel = func() error { if cmd.Process == nil { return nil } if sig := signal.SignalFromContext(ctx); sig != nil { return cmd.Process.Signal(sig) } return cmd.Process.Signal(os.Kill) } data, err := cmd.CombinedOutput() if len(data) > 0 { // diff exits with a non-zero status when the files don't match. // Ignore that failure as long as we get output. err = nil } return data, err } ================================================ FILE: internal/cli/commands/hcl/format/format_bench_test.go ================================================ package format_test import ( "context" "fmt" "io" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/pkg/log" logformat "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" ) func BenchmarkFormat(b *testing.B) { sourceFile := "../../../../../test/fixtures/hcl-filter/fmt/needs-formatting/nested/api/terragrunt.hcl" pristineContent, err := os.ReadFile(sourceFile) if err != nil { b.Fatalf("Failed to read source file: %v", err) } fileCounts := []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024} for _, fileCount := range fileCounts { b.Run(fmt.Sprintf("files_%d", fileCount), func(b *testing.B) { tmpBase := b.TempDir() var excludeList []string for i := 2; i <= fileCount; i += 2 { excludeList = append(excludeList, fmt.Sprintf("dir-%04d", i)) } tgOptions, err := options.NewTerragruntOptionsForTest("") if err != nil { b.Fatalf("Failed to create options: %v", err) } tgOptions.WorkingDir = tmpBase tgOptions.HclExclude = excludeList tgOptions.Writers.Writer = io.Discard tgOptions.Writers.ErrWriter = io.Discard formatter := logformat.NewFormatter(logformat.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) l := log.New(log.WithOutput(io.Discard), log.WithLevel(log.ErrorLevel), log.WithFormatter(formatter)) ctx := context.Background() b.ResetTimer() for b.Loop() { b.StopTimer() if err := createFiles(tmpBase, pristineContent, fileCount); err != nil { b.Fatalf("Failed to create files: %v", err) } b.StartTimer() if err := format.Run(ctx, l, tgOptions); err != nil { b.Fatalf("format.Run failed: %v", err) } } }) } } func createFiles(workingDir string, content []byte, count int) error { entries, err := os.ReadDir(workingDir) if err != nil { return err } for _, entry := range entries { if entry.IsDir() && strings.HasPrefix(entry.Name(), "dir-") { if err := os.RemoveAll(filepath.Join(workingDir, entry.Name())); err != nil { return err } } } for i := 1; i <= count; i++ { dirName := fmt.Sprintf("dir-%04d", i) dirPath := filepath.Join(workingDir, dirName) nestedPath := filepath.Join(dirPath, "nested", "deep", "structure") if err := os.MkdirAll(nestedPath, 0755); err != nil { return err } filePath := filepath.Join(nestedPath, "terragrunt.hcl") if err := os.WriteFile(filePath, content, 0644); err != nil { return err } } return nil } ================================================ FILE: internal/cli/commands/hcl/format/format_test.go ================================================ package format_test import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func TestHCLFmt(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("./testdata/fixtures", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := util.ReadFileAsString("./testdata/fixtures/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) tgOptions.WorkingDir = tmpPath tgOptions.HclExclude = []string{".history"} err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) t.Run("group", func(t *testing.T) { t.Parallel() dirs := []string{ "terragrunt.hcl", "a/terragrunt.hcl", "a/b/c/terragrunt.hcl", "a/b/c/d/services.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range dirs { // Capture range variable into for block so it doesn't change while looping t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual) }) } // check to make sure the file in the `.terragrunt-cache` folder was ignored and untouched t.Run("terragrunt-cache", func(t *testing.T) { t.Parallel() originalTgHclPath := "./testdata/fixtures/ignored/.terragrunt-cache/terragrunt.hcl" original, err := util.ReadFileAsString(originalTgHclPath) require.NoError(t, err) tgHclPath := filepath.Join(tmpPath, "ignored/.terragrunt-cache/terragrunt.hcl") actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, original, actual) }) // Finally, check to make sure the file in the `.history` folder was ignored and untouched t.Run("history", func(t *testing.T) { t.Parallel() originalTgHclPath := "./testdata/fixtures/ignored/.history/terragrunt.hcl" original, err := util.ReadFileAsString(originalTgHclPath) require.NoError(t, err) tgHclPath := filepath.Join(tmpPath, "ignored/.history/terragrunt.hcl") actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, original, actual) }) }) } func TestHCLFmtErrors(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("../../../../../test/fixtures/hclfmt-errors", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) dirs := []string{ "dangling-attribute", "invalid-character", "invalid-key", } for _, dir := range dirs { // Capture range variable into for block so it doesn't change while looping t.Run(dir, func(t *testing.T) { t.Parallel() tgHclDir := filepath.Join(tmpPath, dir) l, newTgOptions, err := tgOptions.CloneWithConfigPath(logger.CreateLogger(), tgOptions.TerragruntConfigPath) require.NoError(t, err) newTgOptions.WorkingDir = tgHclDir err = format.Run(t.Context(), l, newTgOptions) require.Error(t, err) }) } } func TestHCLFmtCheck(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("../../../../../test/fixtures/hclfmt-check", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := os.ReadFile("../../../../../test/fixtures/hclfmt-check/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) tgOptions.Check = true tgOptions.WorkingDir = tmpPath err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) dirs := []string{ "terragrunt.hcl", "a/terragrunt.hcl", "a/b/c/terragrunt.hcl", "a/b/c/d/services.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range dirs { // Capture range variable into for block so it doesn't change while looping t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := os.ReadFile(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual) }) } } func TestHCLFmtCheckErrors(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("../../../../../test/fixtures/hclfmt-check-errors", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := os.ReadFile("../../../../../test/fixtures/hclfmt-check-errors/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) tgOptions.Check = true tgOptions.WorkingDir = tmpPath err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.Error(t, err) dirs := []string{ "terragrunt.hcl", "a/terragrunt.hcl", "a/b/c/terragrunt.hcl", "a/b/c/d/services.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range dirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := os.ReadFile(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual) }) } } func TestHCLFmtFile(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("./testdata/fixtures", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := os.ReadFile("./testdata/fixtures/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) // format only the hcl file contained within the a subdirectory of the fixture tgOptions.HclFile = "a/terragrunt.hcl" tgOptions.WorkingDir = tmpPath err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) // test that the formatting worked on the specified file t.Run("formatted", func(t *testing.T) { t.Run(tgOptions.HclFile, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, tgOptions.HclFile) formatted, readErr := os.ReadFile(tgHclPath) require.NoError(t, readErr) assert.Equal(t, expected, formatted) }) }) dirs := []string{ "terragrunt.hcl", "a/b/c/terragrunt.hcl", } original, err := os.ReadFile("./testdata/fixtures/terragrunt.hcl") require.NoError(t, err) // test that none of the other files were formatted for _, dir := range dirs { // Capture range variable into for block so it doesn't change while looping t.Run(dir, func(t *testing.T) { t.Parallel() testingPath := filepath.Join(tmpPath, dir) actual, err := os.ReadFile(testingPath) require.NoError(t, err) assert.Equal(t, original, actual) }) } } func TestHCLFmtStdin(t *testing.T) { t.Parallel() realStdin := os.Stdin realStdout := os.Stdout tempStdoutFile, err := os.CreateTemp(helpers.TmpDirWOSymlinks(t), "stdout.hcl") defer func() { _ = tempStdoutFile.Close() }() require.NoError(t, err) os.Stdout = tempStdoutFile defer func() { os.Stdout = realStdout }() os.Stdin, err = os.Open("../../../../../test/fixtures/hclfmt-stdin/terragrunt.hcl") defer func() { os.Stdin = realStdin }() require.NoError(t, err) expected, err := os.ReadFile("../../../../../test/fixtures/hclfmt-stdin/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) // format hcl from stdin tgOptions.HclFromStdin = true err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) formatted, err := os.ReadFile(tempStdoutFile.Name()) require.NoError(t, err) assert.Equal(t, expected, formatted) } func TestHCLFmtHeredoc(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("../../../../../test/fixtures/hclfmt-heredoc", t.Name(), func(path string) bool { return true }) defer os.RemoveAll(tmpPath) require.NoError(t, err) expected, err := os.ReadFile("../../../../../test/fixtures/hclfmt-heredoc/expected.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) tgOptions.WorkingDir = tmpPath err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) tgHclPath := filepath.Join(tmpPath, "terragrunt.hcl") actual, err := os.ReadFile(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual) } func TestHCLFmtFilter(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("./testdata/fixtures", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := util.ReadFileAsString("./testdata/fixtures/expected.hcl") require.NoError(t, err) original, err := util.ReadFileAsString("./testdata/fixtures/terragrunt.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) err = tgOptions.Experiments.EnableExperiment("filter-flag") require.NoError(t, err) tgOptions.WorkingDir = tmpPath filters, parseErr := filter.ParseFilterQueries(logger.CreateLogger(), []string{"./a/b/**"}) require.NoError(t, parseErr) tgOptions.Filters = filters err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) t.Run("group", func(t *testing.T) { t.Parallel() formattedDirs := []string{ "a/b/c/terragrunt.hcl", "a/b/c/d/services.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range formattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual, "File %s should be formatted", dir) }) } unformattedDirs := []string{ "terragrunt.hcl", "a/terragrunt.hcl", } for _, dir := range unformattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, original, actual, "File %s should NOT be formatted", dir) }) } }) } func TestHCLFmtFilterMultiple(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("./testdata/fixtures", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := util.ReadFileAsString("./testdata/fixtures/expected.hcl") require.NoError(t, err) original, err := util.ReadFileAsString("./testdata/fixtures/terragrunt.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) err = tgOptions.Experiments.EnableExperiment("filter-flag") require.NoError(t, err) tgOptions.WorkingDir = tmpPath filters, parseErr := filter.ParseFilterQueries(logger.CreateLogger(), []string{ filepath.Join(tmpPath, "terragrunt.hcl"), "./a/b/c/d/e/**", }) require.NoError(t, parseErr) tgOptions.Filters = filters err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) t.Run("group", func(t *testing.T) { t.Parallel() formattedDirs := []string{ "terragrunt.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range formattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual, "File %s should be formatted", dir) }) } unformattedDirs := []string{ "a/terragrunt.hcl", "a/b/c/terragrunt.hcl", "a/b/c/d/services.hcl", } for _, dir := range unformattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, original, actual, "File %s should NOT be formatted", dir) }) } }) } func TestHCLFmtFilterNegation(t *testing.T) { t.Parallel() tmpPath, err := util.CopyFolderToTemp("./testdata/fixtures", t.Name(), func(path string) bool { return true }) t.Cleanup(func() { os.RemoveAll(tmpPath) }) require.NoError(t, err) expected, err := util.ReadFileAsString("./testdata/fixtures/expected.hcl") require.NoError(t, err) original, err := util.ReadFileAsString("./testdata/fixtures/terragrunt.hcl") require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) err = tgOptions.Experiments.EnableExperiment("filter-flag") require.NoError(t, err) tgOptions.WorkingDir = tmpPath filters, parseErr := filter.ParseFilterQueries(logger.CreateLogger(), []string{ "./a/**", "!./a/b/c/d/**", }) require.NoError(t, parseErr) tgOptions.Filters = filters err = format.Run(t.Context(), logger.CreateLogger(), tgOptions) require.NoError(t, err) t.Run("group", func(t *testing.T) { t.Parallel() formattedDirs := []string{ "a/terragrunt.hcl", "a/b/c/terragrunt.hcl", } for _, dir := range formattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, expected, actual, "File %s should be formatted", dir) }) } unformattedDirs := []string{ "terragrunt.hcl", "a/b/c/d/services.hcl", "a/b/c/d/e/terragrunt.hcl", } for _, dir := range unformattedDirs { t.Run(dir, func(t *testing.T) { t.Parallel() tgHclPath := filepath.Join(tmpPath, dir) actual, err := util.ReadFileAsString(tgHclPath) require.NoError(t, err) assert.Equal(t, original, actual, "File %s should NOT be formatted", dir) }) } }) } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/a/b/c/d/e/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/a/b/c/d/services.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/a/b/c/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/a/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/expected.hcl ================================================ inputs = { # comments foo = "bar" bar = "baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/ignored/.gitignore ================================================ !.terragrunt-cache ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/ignored/.history/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/ignored/.terragrunt-cache/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/format/testdata/fixtures/terragrunt.hcl ================================================ inputs = { # comments foo = "bar" bar="baz" inputs = "disjoint" disjoint = true listInput = [ "foo", "bar", ] } ================================================ FILE: internal/cli/commands/hcl/validate/cli.go ================================================ package validate import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "validate" StrictFlagName = "strict" InputsFlagName = "inputs" ShowConfigPathFlagName = "show-config-path" JSONFlagName = "json" ) func NewFlags(l log.Logger, opts *options.TerragruntOptions) clihelper.Flags { tgPrefix := flags.Prefix{flags.TgPrefix} terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName) flagSet := clihelper.Flags{ flags.NewFlag( &clihelper.BoolFlag{ Name: StrictFlagName, EnvVars: tgPrefix.EnvVars(StrictFlagName), Destination: &opts.HCLValidateStrict, Usage: "Enables strict mode. When used in combination with the `--inputs` flag, any inputs defined in Terragrunt that are _not_ used in OpenTofu/Terraform will trigger an error.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars( "strict-validate", // `TG_STRICT_VALIDATE` "hclvalidate-strict-validate", // `TG_HCLVALIDATE_STRICT_VALIDATE` ), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("strict-validate"), terragruntPrefixControl), // `TERRAGRUNT_STRICT_VALIDATE` ), flags.NewFlag( &clihelper.BoolFlag{ Name: InputsFlagName, EnvVars: tgPrefix.EnvVars(InputsFlagName), Destination: &opts.HCLValidateInputs, Usage: "Checks if the Terragrunt configured inputs align with OpenTofu/Terraform defined variables.", }, ), flags.NewFlag( &clihelper.BoolFlag{ Name: ShowConfigPathFlagName, EnvVars: tgPrefix.EnvVars(ShowConfigPathFlagName), Usage: "Emit a list of files with invalid configurations after validating all configurations.", Destination: &opts.HCLValidateShowConfigPath, }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclvalidate-strict-validate"), terragruntPrefixControl), // `TG_HCLVALIDATE_STRICT_VALIDATE` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("hclvalidate-show-config-path"), terragruntPrefixControl), // `TERRAGRUNT_HCLVALIDATE_SHOW_CONFIG_PATH` ), flags.NewFlag( &clihelper.BoolFlag{ Name: JSONFlagName, EnvVars: tgPrefix.EnvVars(JSONFlagName), Destination: &opts.HCLValidateJSONOutput, Usage: "Format results in JSON format.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("hclvalidate-json"), terragruntPrefixControl), // `TG_HCLVALIDATE_JSON` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("hclvalidate-json"), terragruntPrefixControl), // `TERRAGRUNT_HCLVALIDATE_JSON` ), shared.NewTFPathFlag(opts), } flagSet = flagSet.Add(shared.NewQueueFlags(opts, nil)...) flagSet = flagSet.Add(shared.NewFilterFlags(l, opts)...) return flagSet } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmd := &clihelper.Command{ Name: CommandName, Usage: "Recursively find HashiCorp Configuration Language (HCL) files and validate them.", Flags: NewFlags(l, opts), DisabledErrorOnUndefinedFlag: true, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/hcl/validate/validate.go ================================================ // Package validate-inputs collects all the terraform variables defined in the target module, and the terragrunt // inputs that are configured, and compare the two to determine if there are any unused inputs or undefined required // inputs. package validate import ( "context" "encoding/json" "fmt" "os" "path/filepath" "slices" "sort" "strings" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/google/shlex" "github.com/hashicorp/hcl/v2" "maps" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/view" "github.com/gruntwork-io/terragrunt/internal/view/diagnostic" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const splitCount = 2 func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.HCLValidateInputs { if opts.HCLValidateShowConfigPath { return errors.Errorf("specifying both -%s and -%s is invalid", ShowConfigPathFlagName, InputsFlagName) } if opts.HCLValidateJSONOutput { return errors.Errorf("specifying both -%s and -%s is invalid", JSONFlagName, InputsFlagName) } return RunValidateInputs(ctx, l, opts) } if opts.HCLValidateStrict { return errors.Errorf("specifying -%s without -%s is invalid", StrictFlagName, InputsFlagName) } return RunValidate(ctx, l, opts) } func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { var diags diagnostic.Diagnostics // Diagnostics handler to collect validation errors diagnosticsHandler := hclparse.WithDiagnosticsHandler(func(file *hcl.File, hclDiags hcl.Diagnostics) (hcl.Diagnostics, error) { for _, hclDiag := range hclDiags { // Only report diagnostics that are actually in the file being parsed, // not errors from dependencies or other files if hclDiag.Subject != nil && file != nil { fileFilename := file.Body.MissingItemRange().Filename diagFilename := hclDiag.Subject.Filename if diagFilename != fileFilename { continue } } newDiag := diagnostic.NewDiagnostic(file, hclDiag) if !diags.Contains(newDiag) { diags = append(diags, newDiag) } } return nil, nil }) opts.SkipOutput = true opts.NonInteractive = true // Create discovery with filter support if experiment enabled d, err := discovery.NewForHCLCommand(l, discovery.HCLCommandOptions{ WorkingDir: opts.WorkingDir, Filters: opts.Filters, Experiments: opts.Experiments, }) if err != nil { return processDiagnostics(l, opts, diags, errors.New(err)) } // We do worktree generation here instead of in the discovery constructor // so that we can defer cleanup in the same context. gitFilters := opts.Filters.UniqueGitFilters() worktrees, parseErr := worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if parseErr != nil { return errors.Errorf("failed to create worktrees: %w", parseErr) } defer func() { cleanupErr := worktrees.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() d = d.WithWorktrees(worktrees) components, err := d.Discover(ctx, l, opts) if err != nil { return processDiagnostics(l, opts, diags, errors.New(err)) } parseOptions := []hclparse.Option{diagnosticsHandler} parseErrs := []error{} for _, c := range components { parseOpts := opts.Clone() parseOpts.WorkingDir = c.Path() if _, ok := c.(*component.Stack); ok { stackFilePath := filepath.Join(c.Path(), config.DefaultStackFile) parseOpts.TerragruntConfigPath = stackFilePath ctx, parser := configbridge.NewParsingContext(ctx, l, parseOpts) values, err := config.ReadValues(ctx, parser, l, c.Path()) if err != nil { parseErrs = append(parseErrs, errors.New(err)) } parser = parser.WithParseOption(parseOptions) if values != nil { parser = parser.WithValues(values) } file, err := hclparse.NewParser(parser.ParserOptions...).ParseFromFile(stackFilePath) if err != nil { parseErrs = append(parseErrs, errors.New(err)) continue } if _, err := config.ParseStackConfig(ctx, l, parser, file, values); err != nil { parseErrs = append(parseErrs, errors.New(err)) } continue } // Determine which config filename to use for a full parse configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } parseOpts.TerragruntConfigPath = filepath.Join(c.Path(), configFilename) _, pctx := configbridge.NewParsingContext(ctx, l, parseOpts) if _, err := config.ReadTerragruntConfig(ctx, l, pctx, parseOptions); err != nil { parseErrs = append(parseErrs, errors.New(err)) } } var combinedErr error if len(parseErrs) > 0 { combinedErr = errors.Join(parseErrs...) } return processDiagnostics(l, opts, diags, combinedErr) } func processDiagnostics(l log.Logger, opts *options.TerragruntOptions, diags diagnostic.Diagnostics, callErr error) error { if len(diags) == 0 { return callErr } sort.Slice(diags, func(i, j int) bool { var a, b string if diags[i].Range != nil { a = diags[i].Range.Filename } if diags[j].Range != nil { b = diags[j].Range.Filename } return a < b }) if err := writeDiagnostics(l, opts, diags); err != nil { return err } diagError := errors.Errorf("%d HCL validation error(s) found", len(diags)) // If diagnostics exist and no other error was returned, // return a synthetic error to mark validation as failed and // ensure a non-zero exit code from Terragrunt. if callErr == nil { return diagError } return errors.Join(callErr, diagError) } func writeDiagnostics(l log.Logger, opts *options.TerragruntOptions, diags diagnostic.Diagnostics) error { render := view.NewHumanRender(l.Formatter().DisabledColors()) if opts.HCLValidateJSONOutput { render = view.NewJSONRender() } writer := view.NewWriter(opts.Writers.Writer, render) if opts.HCLValidateShowConfigPath { return writer.ShowConfigPath(diags) } return writer.Diagnostics(diags) } func RunValidateInputs(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { opts = opts.Clone() opts.SkipOutput = true opts.NonInteractive = true d, err := discovery.NewForHCLCommand(l, discovery.HCLCommandOptions{ WorkingDir: opts.WorkingDir, Filters: opts.Filters, Experiments: opts.Experiments, }) if err != nil { return err } if opts.Experiments.Evaluate(experiment.FilterFlag) { gitFilters := opts.Filters.UniqueGitFilters() worktrees, worktreeErr := worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if worktreeErr != nil { return errors.Errorf("failed to create worktrees: %w", worktreeErr) } defer func() { cleanupErr := worktrees.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() d = d.WithWorktrees(worktrees) } components, err := d.Discover(ctx, l, opts) if err != nil { return err } r := report.NewReport() var errs []error for _, c := range components { // Skip stacks, only validate inputs for units if _, ok := c.(*component.Stack); ok { continue } unitOpts := opts.Clone() unitOpts.WorkingDir = c.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(c.Path(), configFilename) prepared, err := prepare.PrepareConfig(ctx, l, unitOpts) if err != nil { errs = append(errs, err) continue } // Download source updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) if err != nil { errs = append(errs, err) continue } // Generate config if err := prepare.PrepareGenerate(l, updatedOpts, prepared.Cfg.ToRunConfig(l)); err != nil { errs = append(errs, err) continue } if err := runValidateInputs(l, updatedOpts, prepared.Cfg); err != nil { errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func runValidateInputs(l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig) error { required, optional, err := tf.ModuleVariables(opts.WorkingDir) if err != nil { return err } allVars := slices.Concat(required, optional) allInputs, err := getDefinedTerragruntInputs(l, opts, cfg) if err != nil { return err } // Unused variables are those that are passed in by terragrunt, but are not defined in terraform. unusedVars := []string{} for _, varName := range allInputs { if !slices.Contains(allVars, varName) { unusedVars = append(unusedVars, varName) } } // Missing variables are those that are required by the terraform config, but not defined in terragrunt. missingVars := []string{} for _, varName := range required { if !slices.Contains(allInputs, varName) { missingVars = append(missingVars, varName) } } // Now print out all the information if len(unusedVars) > 0 { l.Warn("The following inputs passed in by terragrunt are unused:\n") for _, varName := range unusedVars { l.Warnf("\t- %s", varName) } l.Warn("") } else { l.Info("All variables passed in by terragrunt are in use.") l.Debug(fmt.Sprintf("Strict mode enabled: %t", opts.HCLValidateStrict)) } if len(missingVars) > 0 { l.Error("The following required inputs are missing:\n") for _, varName := range missingVars { l.Errorf("\t- %s", varName) } l.Error("") } else { l.Info("All required inputs are passed in by terragrunt") l.Debug(fmt.Sprintf("Strict mode enabled: %t", opts.HCLValidateStrict)) } // Return an error when there are misaligned inputs. Terragrunt strict mode defaults to false. When it is false, // an error will only be returned if required inputs are missing. When strict mode is true, an error will be // returned if required inputs are missing OR if any unused variables are passed if len(missingVars) > 0 || len(unusedVars) > 0 && opts.HCLValidateStrict { return errors.New("terragrunt configuration has inputs that are not defined in the OpenTofu/Terraform module. This is not allowed when strict mode is enabled") } else if len(unusedVars) > 0 { l.Warn("Terragrunt configuration has misaligned inputs, but running in relaxed mode so ignoring.") } return nil } // getDefinedTerragruntInputs will return a list of names of all variables that are configured by terragrunt to be // passed into terraform. Terragrunt can pass in inputs from: // - var files defined on terraform.extra_arguments blocks. // - -var and -var-file args passed in on extra_arguments CLI args. // - env vars defined on terraform.extra_arguments blocks. // - env vars from the external runtime calling terragrunt. // - inputs blocks. // - automatically injected terraform vars (terraform.tfvars, terraform.tfvars.json, *.auto.tfvars, *.auto.tfvars.json) func getDefinedTerragruntInputs(l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig) ([]string, error) { envVarTFVars := getTerraformInputNamesFromEnvVar(opts, cfg) inputsTFVars := getTerraformInputNamesFromConfig(cfg) varFileTFVars, err := getTerraformInputNamesFromVarFiles(l, cfg) if err != nil { return nil, err } cliArgsTFVars, err := getTerraformInputNamesFromCLIArgs(l, opts, cfg) if err != nil { return nil, err } autoVarFileTFVars, err := getTerraformInputNamesFromAutomaticVarFiles(l, opts) if err != nil { return nil, err } // Dedupe the input vars. We use a map as a set to accomplish this. tmpOut := map[string]bool{} for _, varName := range envVarTFVars { tmpOut[varName] = true } for _, varName := range inputsTFVars { tmpOut[varName] = true } for _, varName := range varFileTFVars { tmpOut[varName] = true } for _, varName := range cliArgsTFVars { tmpOut[varName] = true } for _, varName := range autoVarFileTFVars { tmpOut[varName] = true } out := []string{} for varName := range tmpOut { out = append(out, varName) } return out, nil } // getTerraformInputNamesFromEnvVar will check the runtime environment variables and the configured environment // variables from extra_arguments blocks to see if there are any TF_VAR environment variables that set terraform // variables. This will return the list of names of variables that are set in this way by the given terragrunt // configuration. func getTerraformInputNamesFromEnvVar(opts *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) []string { envVars := opts.Env // Make sure to check if there are configured env vars in the parsed terragrunt config. if terragruntConfig.Terraform != nil { for _, arg := range terragruntConfig.Terraform.ExtraArgs { if arg.EnvVars != nil { maps.Copy(envVars, *arg.EnvVars) } } } var ( out = []string{} tfVarPrefix = fmt.Sprintf(tf.EnvNameTFVarFmt, "") ) for envName := range envVars { if after, ok := strings.CutPrefix(envName, tfVarPrefix); ok { inputName := after out = append(out, inputName) } } return out } // getTerraformInputNamesFromConfig will return the list of names of variables configured by the inputs block in the // terragrunt config. func getTerraformInputNamesFromConfig(terragruntConfig *config.TerragruntConfig) []string { out := make([]string, 0, len(terragruntConfig.Inputs)) for inputName := range terragruntConfig.Inputs { out = append(out, inputName) } return out } // getTerraformInputNamesFromVarFiles will return the list of names of variables configured by var files set in the // extra_arguments block required_var_files and optional_var_files settings of the given terragrunt config. func getTerraformInputNamesFromVarFiles(l log.Logger, terragruntConfig *config.TerragruntConfig) ([]string, error) { if terragruntConfig.Terraform == nil { return nil, nil } varFiles := []string{} for _, arg := range terragruntConfig.Terraform.ExtraArgs { varFiles = append(varFiles, arg.GetVarFiles(l)...) } return getVarNamesFromVarFiles(l, varFiles) } // getTerraformInputNamesFromCLIArgs will return the list of names of variables configured by -var and -var-file CLI // args that are passed in via the configured arguments attribute in the extra_arguments block of the given terragrunt // config and those that are directly passed in via the CLI. func getTerraformInputNamesFromCLIArgs(l log.Logger, opts *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) ([]string, error) { inputNames, varFiles, err := GetVarFlagsFromArgList(opts.TerraformCliArgs.Slice()) if err != nil { return inputNames, err } if terragruntConfig.Terraform != nil { for _, arg := range terragruntConfig.Terraform.ExtraArgs { if arg.Arguments != nil { vars, rawVarFiles, getArgsErr := GetVarFlagsFromArgList(*arg.Arguments) if getArgsErr != nil { return inputNames, getArgsErr } inputNames = append(inputNames, vars...) varFiles = append(varFiles, rawVarFiles...) } } } fileVars, err := getVarNamesFromVarFiles(l, varFiles) if err != nil { return inputNames, err } inputNames = append(inputNames, fileVars...) return inputNames, nil } // getTerraformInputNamesFromAutomaticVarFiles returns all the variables names func getTerraformInputNamesFromAutomaticVarFiles(l log.Logger, opts *options.TerragruntOptions) ([]string, error) { base := opts.WorkingDir automaticVarFiles := []string{} tfTFVarsFile := filepath.Join(base, "terraform.tfvars") if util.FileExists(tfTFVarsFile) { automaticVarFiles = append(automaticVarFiles, tfTFVarsFile) } tfTFVarsJSONFile := filepath.Join(base, "terraform.tfvars.json") if util.FileExists(tfTFVarsJSONFile) { automaticVarFiles = append(automaticVarFiles, tfTFVarsJSONFile) } varFiles, err := filepath.Glob(filepath.Join(base, "*.auto.tfvars")) if err != nil { return nil, err } automaticVarFiles = append(automaticVarFiles, varFiles...) jsonVarFiles, err := filepath.Glob(filepath.Join(base, "*.auto.tfvars.json")) if err != nil { return nil, err } automaticVarFiles = append(automaticVarFiles, jsonVarFiles...) return getVarNamesFromVarFiles(l, automaticVarFiles) } // getVarNamesFromVarFiles will parse all the given var files and returns a list of names of variables that are // configured in all of them combined together. func getVarNamesFromVarFiles(l log.Logger, varFiles []string) ([]string, error) { inputNames := []string{} for _, varFile := range varFiles { fileVars, err := getVarNamesFromVarFile(l, varFile) if err != nil { return inputNames, err } inputNames = append(inputNames, fileVars...) } return inputNames, nil } // getVarNamesFromVarFile will parse the given terraform var file and return a list of names of variables that are // configured in that var file. func getVarNamesFromVarFile(l log.Logger, varFile string) ([]string, error) { fileContents, err := os.ReadFile(varFile) if err != nil { return nil, err } var variables map[string]any if strings.HasSuffix(varFile, "json") { if err := json.Unmarshal(fileContents, &variables); err != nil { return nil, err } } else { if err := config.ParseAndDecodeVarFile(l, varFile, fileContents, &variables); err != nil { return nil, err } } out := []string{} for varName := range variables { out = append(out, varName) } return out, nil } // GetVarFlagsFromArgList returns the CLI flags defined on the provided arguments list that correspond to -var and -var-file. // Returns two slices, one for `-var` args (the first one) and one for `-var-file` args (the second one). func GetVarFlagsFromArgList(argList []string) ([]string, []string, error) { vars := []string{} varFiles := []string{} for _, arg := range argList { // Use shlex to handle shell style quoting rules. This will reduce quoted args to remove quoting rules. For // example, the string: // -var="'"foo"'"='bar' // becomes: // -var='foo'=bar shlexedArgSlice, err := shlex.Split(arg) if err != nil { return vars, varFiles, err } // Since we expect each element in extra_args.arguments to correspond to a single arg for terraform, we join // back the shlex split slice even if it thinks there are multiple. shlexedArg := strings.Join(shlexedArgSlice, " ") if strings.HasPrefix(shlexedArg, "-var=") { // -var is passed in in the format -var=VARNAME=VALUE, so we split on '=' and take the middle value. splitArg := strings.Split(shlexedArg, "=") if len(splitArg) < splitCount { return vars, varFiles, fmt.Errorf("unexpected -var arg format in terraform.extra_arguments.arguments. Expected '-var=VARNAME=VALUE', got %s", arg) } vars = append(vars, splitArg[1]) } if after, ok := strings.CutPrefix(shlexedArg, "-var-file="); ok { varFiles = append(varFiles, after) } } return vars, varFiles, nil } ================================================ FILE: internal/cli/commands/hcl/validate/validate_test.go ================================================ package validate_test import ( "sort" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/validate" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetVarFlagsFromExtraArgs(t *testing.T) { t.Parallel() testCases := []struct { name string args []string expectedVars []string expectedVarFiles []string }{ { "VarsWithQuotes", []string{`-var='hello=world'`, `-var="foo=bar"`, `-var="'"enabled"'"=false`}, []string{"'enabled'", "foo", "hello"}, []string{}, }, { "VarFilesWithQuotes", []string{`-var-file='terraform.tfvars'`, `-var-file="other_vars.tfvars"`}, []string{}, []string{"other_vars.tfvars", "terraform.tfvars"}, }, { "MixedWithOtherIrrelevantArgs", []string{"-lock=true", "-var=enabled=true", "-refresh=false"}, []string{"enabled"}, []string{}, }, { "None", []string{"-lock=true", "-refresh=false"}, []string{}, []string{}, }, { "SpaceInVarFileName", []string{"-var-file='this is a test.tfvars'"}, []string{}, []string{"this is a test.tfvars"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() vars, varFiles, err := validate.GetVarFlagsFromArgList(tc.args) require.NoError(t, err) sort.Strings(vars) sort.Strings(varFiles) assert.Equal(t, tc.expectedVars, vars) assert.Equal(t, tc.expectedVarFiles, varFiles) }) } } ================================================ FILE: internal/cli/commands/help/cli.go ================================================ // Package help represents the help CLI command that works the same as the `--help` flag. package help import ( "context" "os" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "help" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Show help.", Hidden: true, DisabledErrorOnUndefinedFlag: true, Action: func(ctx context.Context, cliCtx *clihelper.Context) error { return Action(ctx, cliCtx, l, opts) }, } } func Action(ctx context.Context, cliCtx *clihelper.Context, l log.Logger, _ *options.TerragruntOptions) error { var ( args = cliCtx.Args() cmds = cliCtx.Commands ) if l.Level() >= log.DebugLevel { // https: //github.com/urfave/cli/blob/f035ffaa3749afda2cd26fb824aa940747297ef1/help.go#L401 if err := os.Setenv("CLI_TEMPLATE_ERROR_DEBUG", "1"); err != nil { return errors.Errorf("failed to set CLI_TEMPLATE_ERROR_DEBUG environment variable: %w", err) } } if cmdName := args.CommandName(); cmdName == "" || cmds.Get(cmdName) == nil { return clihelper.ShowAppHelp(ctx, cliCtx) } const maxCommandDepth = 1000 // Maximum depth of nested subcommands for i := 0; i < maxCommandDepth && args.Len() > 0; i++ { cmdName := args.CommandName() cmd := cmds.Get(cmdName) if cmd == nil { break } args = args.Remove(cmdName) cmds = cmd.Subcommands cliCtx = cliCtx.NewCommandContext(cmd, args) } if cliCtx.Command != nil { return clihelper.ShowCommandHelp(ctx, cliCtx) } return clihelper.NewExitError(errors.New(clihelper.InvalidCommandNameError(args.First())), clihelper.ExitCodeGeneralError) } ================================================ FILE: internal/cli/commands/info/cli.go ================================================ // Package info represents list of info commands that display various Terragrunt settings. package info import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/info/print" "github.com/gruntwork-io/terragrunt/internal/cli/commands/info/strict" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "info" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "List of commands to display Terragrunt settings.", Subcommands: clihelper.Commands{ strict.NewCommand(l, opts), print.NewCommand(l, opts), }, Action: clihelper.ShowCommandHelp, } } ================================================ FILE: internal/cli/commands/info/print/cli.go ================================================ package print import ( "context" runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "print" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdFlags := runcmd.NewFlags(l, opts, nil) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Print out a short description of Terragrunt context.", UsageText: "terragrunt info print", Flags: cmdFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, } return cmd } ================================================ FILE: internal/cli/commands/info/print/print.go ================================================ // Package print implements the 'terragrunt info print' command that outputs Terragrunt context // information in a structured JSON format. This includes configuration paths, working directories, // IAM roles, and other essential Terragrunt runtime information useful for debugging and // automation purposes. package print import ( "context" "encoding/json" "fmt" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { // If --all flag is set, use discovery to find all units and print info for each one if opts.RunAll { return runAll(ctx, l, opts) } return runPrint(ctx, l, opts) } func runPrint(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { prepared, err := prepare.PrepareConfig(ctx, l, opts) if err != nil { // Even on error, try to print what info we have l.Debugf("Fetching info with error: %v", err) if printErr := printTerragruntContext(l, opts); printErr != nil { l.Errorf("Error printing info: %v", printErr) } return nil } // Download source updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, report.NewReport()) if err != nil { // Even on error, try to print what info we have l.Debugf("Fetching info with error: %v", err) if printErr := printTerragruntContext(l, opts); printErr != nil { l.Errorf("Error printing info: %v", printErr) } return nil } return printTerragruntContext(l, updatedOpts) } func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { d := discovery.NewDiscovery(opts.WorkingDir) components, err := d.Discover(ctx, l, opts) if err != nil { return err } units := components.Filter(component.UnitKind).Sort() var errs []error for _, unit := range units { unitOpts := opts.Clone() unitOpts.WorkingDir = unit.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) if err := runPrint(ctx, l, unitOpts); err != nil { if opts.FailFast { return err } l.Errorf("Print failed: %v", err) errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } // InfoOutput represents the structured output of the info command type InfoOutput struct { ConfigPath string `json:"config_path"` DownloadDir string `json:"download_dir"` IAMRole string `json:"iam_role"` TerraformBinary string `json:"terraform_binary"` TerraformCommand string `json:"terraform_command"` WorkingDir string `json:"working_dir"` } func printTerragruntContext(l log.Logger, opts *options.TerragruntOptions) error { group := InfoOutput{ ConfigPath: opts.TerragruntConfigPath, DownloadDir: opts.DownloadDir, IAMRole: opts.IAMRoleOptions.RoleARN, TerraformBinary: opts.TFPath, TerraformCommand: opts.TerraformCommand, WorkingDir: opts.WorkingDir, } b, err := json.MarshalIndent(group, "", " ") if err != nil { l.Errorf("JSON error marshalling info") return errors.New(err) } if _, err := fmt.Fprintf(opts.Writers.Writer, "%s\n", b); err != nil { return errors.New(err) } return nil } ================================================ FILE: internal/cli/commands/info/strict/command.go ================================================ // Package strict represents CLI command that displays Terragrunt's strict control settings. // Example usage: // // terragrunt info strict list # List active strict controls // terragrunt info strict list --all # List all strict controls package strict import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/view" "github.com/gruntwork-io/terragrunt/internal/strict/view/plaintext" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "strict" ListCommandName = "list" ShowAllFlagName = "all" ) func NewListFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) return clihelper.Flags{ flags.NewFlag(&clihelper.BoolFlag{ Name: ShowAllFlagName, EnvVars: tgPrefix.EnvVars(ShowAllFlagName), Usage: "Show all controls, including completed ones.", }), } } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Command associated with strict control settings.", Subcommands: clihelper.Commands{ &clihelper.Command{ Name: ListCommandName, Flags: NewListFlags(opts, nil), Usage: "List the strict control settings.", UsageText: "terragrunt info strict list [options] ", Action: ListAction(opts), }, }, Action: clihelper.ShowCommandHelp, } } func ListAction(opts *options.TerragruntOptions) func(ctx context.Context, cliCtx *clihelper.Context) error { return func(_ context.Context, cliCtx *clihelper.Context) error { var allowedStatuses = []strict.Status{ strict.ActiveStatus, } if val, ok := cliCtx.Flag(ShowAllFlagName).Value().Get().(bool); ok && val { allowedStatuses = append(allowedStatuses, strict.CompletedStatus) } controls := opts.StrictControls.FilterByStatus(allowedStatuses...) render := plaintext.NewRender() writer := view.NewWriter(cliCtx.Writer, render) if name := cliCtx.Args().CommandName(); name != "" { control := controls.Find(name) if control == nil { return strict.NewInvalidControlNameError(controls.Names()) } return writer.DetailControl(control) } return writer.List(controls) } } ================================================ FILE: internal/cli/commands/list/cli.go ================================================ // Package list provides the ability to list Terragrunt configurations in your codebase // via the `terragrunt list` command. package list import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "list" CommandAlias = "ls" FormatFlagName = "format" TreeFlagName = "tree" TreeFlagAlias = "T" LongFlagName = "long" LongFlagAlias = "l" HiddenFlagName = "hidden" NoHiddenFlagName = "no-hidden" DependenciesFlagName = "dependencies" ExternalFlagName = "external" DAGFlagName = "dag" QueueConstructAsFlagName = "queue-construct-as" QueueConstructAsFlagAlias = "as" ) func NewFlags(l log.Logger, opts *Options, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) flags := clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: FormatFlagName, EnvVars: tgPrefix.EnvVars(FormatFlagName), Destination: &opts.Format, Usage: "Output format for list results. Valid values: text, tree, long, dot.", DefaultText: FormatText, }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoHiddenFlagName, EnvVars: tgPrefix.EnvVars(NoHiddenFlagName), Destination: &opts.NoHidden, Usage: "Exclude hidden directories from list results.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: HiddenFlagName, EnvVars: tgPrefix.EnvVars(HiddenFlagName), Usage: "Include hidden directories in list results.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { if err := opts.StrictControls.FilterByNames(controls.DeprecatedHiddenFlag).Evaluate(ctx); err != nil { return err } } return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: DependenciesFlagName, EnvVars: tgPrefix.EnvVars(DependenciesFlagName), Destination: &opts.Dependencies, Usage: "Include dependencies in list results (only when using --long).", }), flags.NewFlag(&clihelper.BoolFlag{ Name: ExternalFlagName, EnvVars: tgPrefix.EnvVars(ExternalFlagName), Usage: "Discover external dependencies from initial results, and add them to top-level results (implies discovery of dependencies).", Hidden: true, Action: func(_ context.Context, _ *clihelper.Context, value bool) error { if !value { return nil } pathExpr, err := filter.NewPathFilter("./**") if err != nil { return err } graphExpr := filter.NewGraphExpression(pathExpr).WithDependencies() opts.Filters = append(opts.Filters, filter.NewFilter(graphExpr, graphExpr.String())) return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: TreeFlagName, EnvVars: tgPrefix.EnvVars(TreeFlagName), Destination: &opts.Tree, Usage: "Output in tree format (equivalent to --format=tree).", Aliases: []string{TreeFlagAlias}, }), flags.NewFlag(&clihelper.BoolFlag{ Name: LongFlagName, EnvVars: tgPrefix.EnvVars(LongFlagName), Destination: &opts.Long, Usage: "Output in long format (equivalent to --format=long).", Aliases: []string{LongFlagAlias}, }), flags.NewFlag(&clihelper.BoolFlag{ Name: DAGFlagName, EnvVars: tgPrefix.EnvVars(DAGFlagName), Destination: &opts.DAG, Usage: "Use DAG mode to sort and group output.", }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: QueueConstructAsFlagName, EnvVars: tgPrefix.EnvVars(QueueConstructAsFlagName), Destination: &opts.QueueConstructAs, Usage: "Construct the queue as if a specific command was run.", Aliases: []string{QueueConstructAsFlagAlias}, }), } return append(flags, shared.NewFilterFlags(l, opts.TerragruntOptions)...) } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdOpts := NewOptions(opts) prefix := flags.Prefix{CommandName} // Base flags for list plus backend/feature flags flags := NewFlags(l, cmdOpts, prefix) flags = append(flags, shared.NewBackendFlags(opts, prefix)...) flags = append(flags, shared.NewFeatureFlags(opts, prefix)...) return &clihelper.Command{ Name: CommandName, Aliases: []string{CommandAlias}, Usage: "List relevant Terragrunt configurations.", Flags: flags, Before: func(_ context.Context, _ *clihelper.Context) error { if cmdOpts.Tree { cmdOpts.Format = FormatTree } if cmdOpts.Long { cmdOpts.Format = FormatLong } if cmdOpts.DAG { cmdOpts.Mode = ModeDAG } // Requesting a specific command to be used for queue construction // implies DAG mode. if cmdOpts.QueueConstructAs != "" { cmdOpts.Mode = ModeDAG } if err := cmdOpts.Validate(); err != nil { return clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } return nil }, Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, cmdOpts) }, } } ================================================ FILE: internal/cli/commands/list/list.go ================================================ package list import ( "context" "fmt" "os" "path/filepath" "sort" "strings" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/tree" "github.com/charmbracelet/x/term" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/os/stdout" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mgutz/ansi" ) // Run runs the list command. func Run(ctx context.Context, l log.Logger, opts *Options) error { d, err := discovery.NewForDiscoveryCommand(l, &discovery.DiscoveryCommandOptions{ WorkingDir: opts.WorkingDir, QueueConstructAs: opts.QueueConstructAs, NoHidden: opts.NoHidden, WithRequiresParse: opts.Dependencies || opts.Mode == ModeDAG, WithRelationships: opts.Dependencies || opts.Mode == ModeDAG, Filters: opts.Filters, Experiments: opts.Experiments, }) if err != nil { return errors.New(err) } // We do worktree generation here instead of in the discovery constructor // so that we can defer cleanup in the same context. gitFilters := opts.Filters.UniqueGitFilters() worktrees, worktreeErr := worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if worktreeErr != nil { return errors.Errorf("failed to create worktrees: %w", worktreeErr) } defer func() { cleanupErr := worktrees.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() d = d.WithWorktrees(worktrees) var ( components component.Components discoverErr error ) // Wrap discovery with telemetry err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "list_discover", map[string]any{ "working_dir": opts.WorkingDir, "no_hidden": opts.NoHidden, "dependencies": opts.Dependencies || opts.Mode == ModeDAG, }, func(ctx context.Context) error { components, discoverErr = d.Discover(ctx, l, opts.TerragruntOptions) return discoverErr }) if err != nil { l.Debugf("Errors encountered while discovering components:\n%s", err) } switch opts.Mode { case ModeNormal: components = components.Sort() case ModeDAG: err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "list_mode_dag", map[string]any{ "working_dir": opts.WorkingDir, "config_count": len(components), }, func(ctx context.Context) error { q, queueErr := queue.NewQueue(components) if queueErr != nil { return queueErr } components = q.Components() return nil }) if err != nil { return errors.New(err) } default: // This should never happen, because of validation in the command. // If it happens, we want to throw so we can fix the validation. return errors.New("invalid mode: " + opts.Mode) } var listedComponents ListedComponents err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "list_discovered_to_listed", map[string]any{ "working_dir": opts.WorkingDir, "config_count": len(components), }, func(ctx context.Context) error { var convErr error listedComponents, convErr = discoveredToListed(components, opts) return convErr }) if err != nil { return errors.New(err) } switch opts.Format { case FormatText: return outputText(l, opts, listedComponents) case FormatTree: return outputTree(l, opts, listedComponents, opts.Mode) case FormatLong: return outputLong(l, opts, listedComponents) case FormatDot: return outputDot(l, opts, listedComponents) default: // This should never happen, because of validation in the command. // If it happens, we want to throw so we can fix the validation. return errors.New("invalid format: " + opts.Format) } } type ListedComponents []*ListedComponent type ListedComponent struct { Type component.Kind Path string Dependencies []*ListedComponent Excluded bool } // Contains checks to see if the given path is in the listed components. func (l ListedComponents) Contains(path string) bool { for _, c := range l { if c.Path == path { return true } } return false } // Get returns the component with the given path. func (l ListedComponents) Get(path string) *ListedComponent { for _, c := range l { if c.Path == path { return c } } return nil } func discoveredToListed(components component.Components, opts *Options) (ListedComponents, error) { listedComponents := make(ListedComponents, 0, len(components)) errs := []error{} for _, c := range components { excluded := false if opts.QueueConstructAs != "" { if unit, ok := c.(*component.Unit); ok { if cfg := unit.Config(); cfg != nil && cfg.Exclude != nil { if cfg.Exclude.IsActionListed(opts.QueueConstructAs) { if opts.Format != FormatDot { continue } excluded = true } } } } var ( relPath string err error ) if c.DiscoveryContext() != nil && c.DiscoveryContext().WorkingDir != "" { relPath, err = filepath.Rel(c.DiscoveryContext().WorkingDir, c.Path()) } else { relPath, err = filepath.Rel(opts.WorkingDir, c.Path()) } if err != nil { errs = append(errs, errors.New(err)) continue } listedCfg := &ListedComponent{ Type: c.Kind(), Path: relPath, Excluded: excluded, } if len(c.Dependencies()) == 0 { listedComponents = append(listedComponents, listedCfg) continue } listedCfg.Dependencies = make([]*ListedComponent, len(c.Dependencies())) for i, dep := range c.Dependencies() { var relDepPath string if dep.DiscoveryContext() != nil && dep.DiscoveryContext().WorkingDir != "" { relDepPath, err = filepath.Rel(dep.DiscoveryContext().WorkingDir, dep.Path()) } else { relDepPath, err = filepath.Rel(opts.WorkingDir, dep.Path()) } if err != nil { errs = append(errs, errors.New(err)) continue } depExcluded := false if opts.QueueConstructAs != "" { if depUnit, ok := dep.(*component.Unit); ok { if depCfg := depUnit.Config(); depCfg != nil && depCfg.Exclude != nil { if depCfg.Exclude.IsActionListed(opts.QueueConstructAs) { depExcluded = true } } } } listedCfg.Dependencies[i] = &ListedComponent{ Type: dep.Kind(), Path: relDepPath, Excluded: depExcluded, } } sort.SliceStable(listedCfg.Dependencies, func(i, j int) bool { return listedCfg.Dependencies[i].Path < listedCfg.Dependencies[j].Path }) listedComponents = append(listedComponents, listedCfg) } return listedComponents, errors.Join(errs...) } // Colorizer is a colorizer for the discovered components. type Colorizer struct { unitColorizer func(string) string stackColorizer func(string) string headingColorizer func(string) string pathColorizer func(string) string } // NewColorizer creates a new Colorizer. func NewColorizer(shouldColor bool) *Colorizer { if !shouldColor { return &Colorizer{ unitColorizer: func(s string) string { return s }, stackColorizer: func(s string) string { return s }, headingColorizer: func(s string) string { return s }, pathColorizer: func(s string) string { return s }, } } return &Colorizer{ unitColorizer: ansi.ColorFunc("blue+bh"), stackColorizer: ansi.ColorFunc("green+bh"), headingColorizer: ansi.ColorFunc("yellow+bh"), pathColorizer: ansi.ColorFunc("white+d"), } } func (c *Colorizer) Colorize(listedComponent *ListedComponent) string { path := listedComponent.Path // Get the directory and base name using filepath dir, base := filepath.Split(path) if dir == "" { // No directory part, color the whole path switch listedComponent.Type { case component.UnitKind: return c.unitColorizer(path) case component.StackKind: return c.stackColorizer(path) default: return path } } // Color the components differently coloredPath := c.pathColorizer(dir) switch listedComponent.Type { case component.UnitKind: return coloredPath + c.unitColorizer(base) case component.StackKind: return coloredPath + c.stackColorizer(base) default: return path } } func (c *Colorizer) ColorizeType(t component.Kind) string { switch t { case component.UnitKind: // This extra space is to keep unit and stack // output equally spaced. return c.unitColorizer("unit ") case component.StackKind: return c.stackColorizer("stack") default: return string(t) } } func (c *Colorizer) ColorizeHeading(dep string) string { return c.headingColorizer(dep) } // outputText outputs the discovered components in text format. func outputText(l log.Logger, opts *Options, components ListedComponents) error { colorizer := NewColorizer(shouldColor(l)) return renderTabular(opts, components, colorizer) } // outputLong outputs the discovered components in long format. func outputLong(l log.Logger, opts *Options, components ListedComponents) error { colorizer := NewColorizer(shouldColor(l)) return renderLong(opts, components, colorizer) } // shouldColor returns true if the output should be colored. func shouldColor(l log.Logger) bool { return !l.Formatter().DisabledColors() && !stdout.IsRedirected() } // renderLong renders the components in a long format. func renderLong(opts *Options, components ListedComponents, c *Colorizer) error { var buf strings.Builder longestPathLen := getLongestPathLen(components) buf.WriteString(buildLongHeadings(opts, c, longestPathLen)) for _, component := range components { buf.WriteString(c.ColorizeType(component.Type)) buf.WriteString(" " + c.Colorize(component)) if opts.Dependencies && len(component.Dependencies) > 0 { colorizedDeps := make([]string, 0, len(component.Dependencies)) for _, dep := range component.Dependencies { colorizedDeps = append(colorizedDeps, c.Colorize(dep)) } const extraDependenciesPadding = 2 dependenciesPadding := (longestPathLen - len(component.Path)) + extraDependenciesPadding for range dependenciesPadding { buf.WriteString(" ") } buf.WriteString(strings.Join(colorizedDeps, ", ")) } buf.WriteString("\n") } _, err := opts.Writers.Writer.Write([]byte(buf.String())) return errors.New(err) } // buildLongHeadings renders the headings for the long format. func buildLongHeadings(opts *Options, c *Colorizer, longestPathLen int) string { var buf strings.Builder buf.WriteString(c.ColorizeHeading("Type Path")) if opts.Dependencies { const extraDependenciesPadding = 2 dependenciesPadding := (longestPathLen - len("Path")) + extraDependenciesPadding for range dependenciesPadding { buf.WriteString(" ") } buf.WriteString(c.ColorizeHeading("Dependencies")) } buf.WriteString("\n") return buf.String() } // renderTabular renders the components in a tabular format. func renderTabular(opts *Options, components ListedComponents, c *Colorizer) error { var buf strings.Builder maxCols, colWidth := getMaxCols(components) for i, component := range components { if i > 0 && i%maxCols == 0 { buf.WriteString("\n") } buf.WriteString(c.Colorize(component)) // Add padding until the length of maxCols padding := colWidth - len(component.Path) for range padding { buf.WriteString(" ") } } buf.WriteString("\n") _, err := opts.Writers.Writer.Write([]byte(buf.String())) return errors.New(err) } // outputTree outputs the discovered components in tree format. func outputTree(l log.Logger, opts *Options, components ListedComponents, sort string) error { s := NewTreeStyler(shouldColor(l)) return renderTree(opts, components, s, sort) } // outputDot outputs the discovered components in GraphViz DOT format. func outputDot(_ log.Logger, opts *Options, components ListedComponents) error { return renderDot(opts, components) } type TreeStyler struct { entryStyle lipgloss.Style rootStyle lipgloss.Style colorizer *Colorizer shouldColor bool } func NewTreeStyler(shouldColor bool) *TreeStyler { colorizer := NewColorizer(shouldColor) return &TreeStyler{ shouldColor: shouldColor, entryStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginRight(1), rootStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("35")), colorizer: colorizer, } } func (s *TreeStyler) Style(t *tree.Tree) *tree.Tree { t = t. Enumerator(tree.RoundedEnumerator) if !s.shouldColor { return t } return t. EnumeratorStyle(s.entryStyle). RootStyle(s.rootStyle) } // generateTree creates a tree structure from ListedComponents func generateTree(components ListedComponents, s *TreeStyler) *tree.Tree { root := tree.Root(".") nodes := make(map[string]*tree.Tree) for _, c := range components { parts := preProcessPath(c.Path) if len(parts.segments) == 0 || (len(parts.segments) == 1 && parts.segments[0] == ".") { continue } currentPath := "." currentNode := root for i, segment := range parts.segments { nextPath := filepath.Join(currentPath, segment) if _, exists := nodes[nextPath]; !exists { componentType := component.StackKind if c.Type == component.UnitKind && i == len(parts.segments)-1 { componentType = component.UnitKind } tmpCfg := &ListedComponent{ Type: componentType, Path: segment, } newNode := tree.New().Root(s.colorizer.Colorize(tmpCfg)) nodes[nextPath] = newNode currentNode.Child(newNode) } currentNode = nodes[nextPath] currentPath = nextPath } } return root } // generateDAGTree creates a tree structure from ListedComponents. // It assumes that the components are already sorted in DAG order. // As such, it will first construct root nodes for each component // without a dependency in the listed components. Then, it will // connect the remaining nodes to their dependencies, which // should be doable in a single pass through the components. // There may be duplicate entries for dependency nodes, as // a node may be a dependency for multiple components. // That's OK. func generateDAGTree(components ListedComponents, s *TreeStyler) *tree.Tree { root := tree.Root(".") rootNodes := make(map[string]*tree.Tree) dependencyNodes := make(map[string]*tree.Tree) // First pass: create all root nodes for _, c := range components { if len(c.Dependencies) == 0 || !components.Contains(c.Path) { rootNodes[c.Path] = tree.New().Root(s.colorizer.Colorize(c)) } } // Second pass: connect dependencies for _, c := range components { if len(c.Dependencies) == 0 { continue } // Sort dependencies to ensure deterministic order sortedDeps := make([]string, len(c.Dependencies)) for i, dep := range c.Dependencies { sortedDeps[i] = dep.Path } sort.Strings(sortedDeps) for _, dependency := range sortedDeps { if _, exists := rootNodes[dependency]; exists { dependencyNode := tree.New().Root(s.colorizer.Colorize(c)) rootNodes[dependency].Child(dependencyNode) dependencyNodes[c.Path] = dependencyNode continue } if _, exists := dependencyNodes[dependency]; exists { newDependencyNode := tree.New().Root(s.colorizer.Colorize(c)) dependencyNodes[dependency].Child(newDependencyNode) dependencyNodes[c.Path] = newDependencyNode } } } // Sort root nodes to ensure deterministic order sortedRootPaths := make([]string, 0, len(rootNodes)) for path := range rootNodes { sortedRootPaths = append(sortedRootPaths, path) } sort.Strings(sortedRootPaths) // Add root nodes in sorted order for _, path := range sortedRootPaths { root.Child(rootNodes[path]) } return root } // pathParts holds the pre-processed parts of a component path. type pathParts struct { dir string base string segments []string } // preProcessPath splits a path into its components. func preProcessPath(path string) pathParts { dir := filepath.Dir(path) base := filepath.Base(path) segments := strings.Split(path, string(os.PathSeparator)) return pathParts{ dir: dir, base: base, segments: segments, } } // renderTree renders the components in a tree format. func renderTree(opts *Options, components ListedComponents, s *TreeStyler, _ string) error { var t *tree.Tree if opts.Mode == ModeDAG { t = generateDAGTree(components, s) } else { t = generateTree(components, s) } t = s.Style(t) _, err := opts.Writers.Writer.Write([]byte(t.String() + "\n")) if err != nil { return errors.New(err) } return nil } // getMaxCols returns the maximum number of columns // that can be displayed in the terminal. // It also returns the width of each column. // The width is the longest path length + 2 for padding. func getMaxCols(components ListedComponents) (int, int) { maxCols := 0 terminalWidth := getTerminalWidth() longestPathLen := getLongestPathLen(components) const padding = 2 colWidth := longestPathLen + padding if longestPathLen > 0 { maxCols = terminalWidth / colWidth } if maxCols == 0 { maxCols = 1 } return maxCols, colWidth } // getTerminalWidth returns the width of the terminal. func getTerminalWidth() int { // Default to 80 if we can't get the terminal width. width := 80 w, _, err := term.GetSize(os.Stdout.Fd()) if err == nil { width = w } return width } // getLongestPathLen returns the length of the // longest path in the list of components. func getLongestPathLen(components ListedComponents) int { longest := 0 for _, c := range components { if len(c.Path) > longest { longest = len(c.Path) } } return longest } // renderDot renders the components in GraphViz DOT format. func renderDot(opts *Options, components ListedComponents) error { var buf strings.Builder buf.WriteString("digraph {\n") sortedComponents := make(ListedComponents, len(components)) copy(sortedComponents, components) sort.Slice(sortedComponents, func(i, j int) bool { return sortedComponents[i].Path < sortedComponents[j].Path }) for _, component := range sortedComponents { if len(component.Dependencies) > 1 { sort.Slice(component.Dependencies, func(i, j int) bool { return component.Dependencies[i].Path < component.Dependencies[j].Path }) } } for _, component := range sortedComponents { style := "" if component.Excluded { style = "[color=red]" } buf.WriteString(fmt.Sprintf("\t\"%s\" %s;\n", component.Path, style)) for _, dep := range component.Dependencies { buf.WriteString(fmt.Sprintf("\t\"%s\" -> \"%s\";\n", component.Path, dep.Path)) } } buf.WriteString("}\n") _, err := opts.Writers.Writer.Write([]byte(buf.String())) return errors.New(err) } ================================================ FILE: internal/cli/commands/list/list_test.go ================================================ package list_test import ( "io" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/list" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBasicDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "stack1", ".hidden/unit3", "nested/unit4", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "stack1/terragrunt.stack.hcl": "", ".hidden/unit3/terragrunt.hcl": "", "nested/unit4/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } expectedPaths := []string{"unit1", "unit2", filepath.Join("nested", "unit4"), "stack1"} tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir // Create options opts := list.NewOptions(tgOpts) opts.Format = "text" //nolint: goconst opts.Mode = "normal" opts.NoHidden = true opts.Dependencies = false // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) err = list.Run(t.Context(), l, opts) require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Split output into fields and trim whitespace fields := strings.Fields(string(output)) // Verify we have the expected number of lines assert.Len(t, fields, len(expectedPaths)) // Verify each line is a clean path without any formatting for _, field := range fields { assert.NotEmpty(t, field) } // Verify all expected paths are present assert.ElementsMatch(t, expectedPaths, fields) } func TestHiddenDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "stack1", ".hidden/unit3", "nested/unit4", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "stack1/terragrunt.stack.hcl": "", ".hidden/unit3/terragrunt.hcl": "", "nested/unit4/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } expectedPaths := []string{"unit1", "unit2", filepath.Join("nested", "unit4"), "stack1", filepath.Join(".hidden", "unit3")} tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) // Create options opts := list.NewOptions(tgOpts) opts.Format = "text" // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Split output into fields and trim whitespace fields := strings.Fields(string(output)) // Verify we have the expected number of lines assert.Len(t, fields, len(expectedPaths)) // Verify all expected paths are present assert.ElementsMatch(t, expectedPaths, fields) } func TestDAGSortingSimpleDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure with dependencies: // unit2 -> unit1 // unit3 -> unit2 testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" }`, "unit3/terragrunt.hcl": ` dependency "unit2" { config_path = "../unit2" }`, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } expectedPaths := []string{"unit1", "unit2", "unit3"} tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) // Create options opts := list.NewOptions(tgOpts) opts.Format = "text" opts.Mode = "dag" //nolint: goconst opts.Dependencies = true // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Split output into fields and trim whitespace fields := strings.Fields(string(output)) // Verify we have the expected number of lines assert.Len(t, fields, len(expectedPaths)) // For DAG sorting, order matters - verify exact order assert.Equal(t, expectedPaths, fields) } func TestDAGSortingReversedDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure with dependencies: // unit3 -> unit2 // unit2 -> unit1 testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ "unit1/terragrunt.hcl": ` dependency "unit2" { config_path = "../unit2" }`, "unit2/terragrunt.hcl": ` dependency "unit3" { config_path = "../unit3" }`, "unit3/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } expectedPaths := []string{"unit3", "unit2", "unit1"} tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) // Create options opts := list.NewOptions(tgOpts) opts.Format = "text" opts.Mode = "dag" //nolint: goconst opts.Dependencies = true // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Split output into fields and trim whitespace fields := strings.Fields(string(output)) // Verify we have the expected number of lines assert.Len(t, fields, len(expectedPaths)) // For DAG sorting, order matters - verify exact order assert.Equal(t, expectedPaths, fields) // Helper to find index of a path findIndex := func(path string) int { for i, field := range fields { if field == path { return i } } return -1 } // Verify dependency ordering unit1Index := findIndex("unit1") unit2Index := findIndex("unit2") unit3Index := findIndex("unit3") assert.Less(t, unit3Index, unit2Index, "unit3 (no deps) should come before unit2 (depends on unit3)") assert.Less(t, unit2Index, unit1Index, "unit2 should come before unit1 (depends on unit2)") } func TestDAGSortingComplexDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure with complex dependencies: // A (no deps) // B (no deps) // C -> A // D -> A,B // E -> C // F -> C testDirs := []string{ "A", "B", "C", "D", "E", "F", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ "A/terragrunt.hcl": "", "B/terragrunt.hcl": "", "C/terragrunt.hcl": ` dependency "A" { config_path = "../A" }`, "D/terragrunt.hcl": ` dependency "A" { config_path = "../A" } dependency "B" { config_path = "../B" }`, "E/terragrunt.hcl": ` dependency "C" { config_path = "../C" }`, "F/terragrunt.hcl": ` dependency "C" { config_path = "../C" }`, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } expectedPaths := []string{"A", "B", "C", "D", "E", "F"} tgOpts := options.NewTerragruntOptions() tgOpts.WorkingDir = tmpDir l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) // Create options opts := list.NewOptions(tgOpts) opts.Format = "text" opts.Mode = "dag" //nolint: goconst opts.Dependencies = true // Create a pipe to capture output r, w, err := os.Pipe() require.NoError(t, err) // Set the writer in options opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) // Close the write end of the pipe w.Close() // Read all output output, err := io.ReadAll(r) require.NoError(t, err) // Split output into fields and trim whitespace fields := strings.Fields(string(output)) // Verify we have the expected number of lines assert.Len(t, fields, len(expectedPaths)) // For DAG sorting, order matters - verify exact order // and also verify relative ordering constraints assert.Equal(t, expectedPaths, fields) // Helper to find index of a path findIndex := func(path string) int { for i, line := range fields { if line == path { return i } } return -1 } // Verify dependency ordering aIndex := findIndex("A") bIndex := findIndex("B") cIndex := findIndex("C") dIndex := findIndex("D") eIndex := findIndex("E") fIndex := findIndex("F") // Level 0 items should be before their dependents assert.Less(t, aIndex, cIndex, "A should come before C") assert.Less(t, aIndex, dIndex, "A should come before D") assert.Less(t, bIndex, dIndex, "B should come before D") // Level 1 items should be before their dependents assert.Less(t, cIndex, eIndex, "C should come before E") assert.Less(t, cIndex, fIndex, "C should come before F") } func TestColorizer(t *testing.T) { t.Parallel() colorizer := list.NewColorizer(true) tests := []struct { name string config *list.ListedComponent // We can't test exact ANSI codes as they might vary by environment, // so we'll test that different types result in different outputs shouldBeDifferent []component.Kind }{ { name: "unit config", config: &list.ListedComponent{ Type: component.UnitKind, Path: "path/to/unit", }, shouldBeDifferent: []component.Kind{component.StackKind}, }, { name: "stack config", config: &list.ListedComponent{ Type: component.StackKind, Path: "path/to/stack", }, shouldBeDifferent: []component.Kind{component.UnitKind}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := colorizer.Colorize(tt.config) assert.NotEmpty(t, result) // Test that different types produce different colorized outputs for _, diffType := range tt.shouldBeDifferent { diffConfig := &list.ListedComponent{ Type: diffType, Path: tt.config.Path, } diffResult := colorizer.Colorize(diffConfig) assert.NotEqual(t, result, diffResult) } }) } } func TestDotFormat(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } `, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Mode = list.ModeDAG opts.Dependencies = true r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" ; "001/unit2" ; "001/unit2" -> "001/unit1"; } `, outputStr, ) } func TestDotFormatWithoutDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": "", "unit3/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Dependencies = false r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" ; "001/unit2" ; "001/unit3" ; } `, outputStr, ) } func TestDotFormatWithComplexDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } `, "unit3/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } dependency "unit2" { config_path = "../unit2" } `, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Mode = list.ModeDAG opts.Dependencies = true r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" ; "001/unit2" ; "001/unit2" -> "001/unit1"; "001/unit3" ; "001/unit3" -> "001/unit1"; "001/unit3" -> "001/unit2"; } `, outputStr, ) } func TestDotFormatWithExcludedComponents(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } exclude { if = true actions = ["apply"] } `, "unit3/terragrunt.hcl": ` dependency "unit2" { config_path = "../unit2" } `, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Mode = list.ModeDAG opts.Dependencies = true opts.QueueConstructAs = "apply" r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" ; "001/unit2" [color=red]; "001/unit2" -> "001/unit1"; "001/unit3" ; "001/unit3" -> "001/unit2"; } `, outputStr, ) } func TestDotFormatWithExcludedDependency(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": ` exclude { if = true actions = ["plan"] } `, "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } `, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Mode = list.ModeDAG opts.Dependencies = true opts.QueueConstructAs = "plan" r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" [color=red]; "001/unit2" ; "001/unit2" -> "001/unit1"; } `, outputStr, ) } func TestTextFormatExcludesExcludedComponents(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": "", "unit2/terragrunt.hcl": ` exclude { if = true actions = ["destroy"] } `, "unit3/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() l.Formatter().SetDisabledColors(true) tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatText opts.QueueConstructAs = "destroy" r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) expectedPaths := []string{filepath.Join("001", "unit1"), filepath.Join("001", "unit3")} fields := strings.Fields(outputStr) assert.Len(t, fields, len(expectedPaths)) assert.ElementsMatch(t, expectedPaths, fields) } func TestDotFormatWithMultipleExcludedComponents(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) testDirs := []string{ "unit1", "unit2", "unit3", "unit4", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } testFiles := map[string]string{ "unit1/terragrunt.hcl": ` exclude { if = true actions = ["all"] } `, "unit2/terragrunt.hcl": ` dependency "unit1" { config_path = "../unit1" } `, "unit3/terragrunt.hcl": ` dependency "unit2" { config_path = "../unit2" } exclude { if = true actions = ["all"] } `, "unit4/terragrunt.hcl": ` dependency "unit3" { config_path = "../unit3" } `, } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() tgOptions, err := options.NewTerragruntOptionsForTest(tmpDir) require.NoError(t, err) opts := list.NewOptions(tgOptions) opts.Format = list.FormatDot opts.Mode = list.ModeDAG opts.Dependencies = true opts.QueueConstructAs = "apply" r, w, err := os.Pipe() require.NoError(t, err) opts.Writers.Writer = w err = list.Run(t.Context(), l, opts) require.NoError(t, err) w.Close() output, err := io.ReadAll(r) require.NoError(t, err) outputStr := string(output) assert.Equal( t, `digraph { "001/unit1" [color=red]; "001/unit2" ; "001/unit2" -> "001/unit1"; "001/unit3" [color=red]; "001/unit3" -> "001/unit2"; "001/unit4" ; "001/unit4" -> "001/unit3"; } `, outputStr, ) } ================================================ FILE: internal/cli/commands/list/options.go ================================================ package list import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( // FormatText outputs the discovered configurations in text format. FormatText = "text" // FormatTree outputs the discovered configurations in tree format. FormatTree = "tree" // FormatLong outputs the discovered configurations in long format. FormatLong = "long" // FormatDot outputs the discovered configurations in GraphViz DOT format. FormatDot = "dot" // SortDAG sorts the discovered configurations in a topological sort order. SortDAG = "dag" // ModeNormal is the default mode for the list command. ModeNormal = "normal" // ModeDAG is the mode for the list command that sorts and groups output in DAG order. ModeDAG = "dag" ) type Options struct { *options.TerragruntOptions // Format determines the format of the output. Format string // Mode determines the mode of the list command. Mode string // QueueConstructAs constructs the queue as if a particular command was run. QueueConstructAs string // NoHidden determines if hidden directories should be excluded from the output. NoHidden bool // Dependencies determines whether to include dependencies in the output. Dependencies bool // Tree determines whether to output in tree format. Tree bool // Long determines whether the output should be in long format. Long bool // DAG determines whether to output in DAG format. DAG bool } func NewOptions(opts *options.TerragruntOptions) *Options { return &Options{ TerragruntOptions: opts, Format: FormatText, Mode: ModeNormal, } } func (o *Options) Validate() error { errs := []error{} if err := o.validateFormat(); err != nil { errs = append(errs, err) } if err := o.validateMode(); err != nil { errs = append(errs, err) } if len(errs) > 0 { return errors.New(errors.Join(errs...)) } return nil } func (o *Options) validateFormat() error { switch o.Format { case FormatText: return nil case FormatTree: return nil case FormatLong: return nil case FormatDot: return nil default: return errors.New("invalid format: " + o.Format) } } func (o *Options) validateMode() error { switch o.Mode { case ModeNormal: return nil case SortDAG: return nil default: return errors.New("invalid mode: " + o.Mode) } } ================================================ FILE: internal/cli/commands/render/cli.go ================================================ // Package render provides the command to render the final terragrunt config in various formats. package render import ( "context" runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "render" FormatFlagName = "format" JSONFlagName = "json" WriteFlagName = "write" WriteAliasFlagName = "w" OutFlagName = "out" WithMetadataFlagName = "with-metadata" DisableDependentModulesFlagName = "disable-dependent-modules" ) func NewFlags(opts *Options, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName) return clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: FormatFlagName, EnvVars: tgPrefix.EnvVars(FormatFlagName), Destination: &opts.Format, Usage: "The output format to render the config in. Currently supports: json", Action: func(_ context.Context, _ *clihelper.Context, value string) error { // Set the default output path based on the format. switch value { case FormatJSON: if opts.OutputPath == "" { opts.OutputPath = "terragrunt.rendered.json" } return nil case FormatHCL: if opts.OutputPath == "" { opts.OutputPath = "terragrunt.rendered.hcl" } return nil default: return errors.New("invalid format: " + value) } }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: JSONFlagName, EnvVars: tgPrefix.EnvVars(JSONFlagName), Usage: "Render the config in JSON format. Equivalent to --format=json.", Action: func(_ context.Context, _ *clihelper.Context, value bool) error { opts.Format = FormatJSON if opts.OutputPath == "" { opts.OutputPath = "terragrunt.rendered.json" } return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: WriteFlagName, EnvVars: tgPrefix.EnvVars(WriteFlagName), Aliases: []string{WriteAliasFlagName}, Destination: &opts.Write, Usage: "Write the rendered config to a file.", }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: OutFlagName, EnvVars: tgPrefix.EnvVars(OutFlagName), Destination: &opts.OutputPath, Usage: "The file name that terragrunt should use when rendering the terragrunt.hcl config (next to the unit configuration).", }, flags.WithDeprecatedFlagName("json-out", terragruntPrefixControl), // `--json-out` (deprecated: use `--out` instead) flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("render-json-out"), terragruntPrefixControl), // `TG_RENDER_JSON_OUT` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("json-out"), terragruntPrefixControl), // `TERRAGRUNT_JSON_OUT` ), flags.NewFlag(&clihelper.BoolFlag{ Name: WithMetadataFlagName, EnvVars: tgPrefix.EnvVars(WithMetadataFlagName), Destination: &opts.RenderMetadata, Usage: "Add metadata to the rendered output file.", }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("render-json-with-metadata"), terragruntPrefixControl), // `TG_RENDER_JSON_WITH_METADATA` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("with-metadata"), terragruntPrefixControl), // `TERRAGRUNT_WITH_METADATA` ), flags.NewFlag(&clihelper.BoolFlag{ Name: DisableDependentModulesFlagName, EnvVars: tgPrefix.EnvVars(DisableDependentModulesFlagName), Hidden: true, Usage: "Deprecated: Disable identification of dependent modules when rendering config. This flag has no effect as dependent modules discovery has been removed.", Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { return opts.StrictControls.FilterByNames(controls.DisableDependentModules).Evaluate(ctx) } return nil }, }, flags.WithDeprecatedEnvVars(tgPrefix.EnvVars("render-json-disable-dependent-modules"), terragruntPrefixControl), // `TG_RENDER_JSON_DISABLE_DEPENDENT_MODULES` flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("json-disable-dependent-modules"), terragruntPrefixControl), // `TERRAGRUNT_JSON_DISABLE_DEPENDENT_MODULES` ), } } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { prefix := flags.Prefix{CommandName} renderOpts := NewOptions(opts) cmdFlags := append(runcmd.NewFlags(l, opts, nil), NewFlags(renderOpts, prefix)...) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, prefix)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Render the final terragrunt config, with all variables, includes, and functions resolved, in the specified format.", Description: "This is useful for enforcing policies using static analysis tools like Open Policy Agent, or for debugging your terragrunt config.", Flags: cmdFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { tgOpts := opts.OptionsFromContext(ctx) clonedOpts := renderOpts.Clone() clonedOpts.TerragruntOptions = tgOpts return Run(ctx, l, clonedOpts) }, } return cmd } ================================================ FILE: internal/cli/commands/render/options.go ================================================ package render import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( // FormatHCL outputs the config in HCL format. FormatHCL = "hcl" // FormatJSON outputs the config in JSON format. FormatJSON = "json" ) type Options struct { *options.TerragruntOptions // Format determines the format of the output. Format string // OutputPath is the path to the file to write the rendered config to. // This configuration is relative to the Terragrunt config path. OutputPath string // Write the rendered config to a file. Write bool // RenderMetadata adds metadata to the rendered config. RenderMetadata bool } func NewOptions(opts *options.TerragruntOptions) *Options { return &Options{ TerragruntOptions: opts, Format: FormatHCL, Write: false, RenderMetadata: false, } } func (o *Options) Clone() *Options { return &Options{ TerragruntOptions: o.TerragruntOptions.Clone(), Format: o.Format, OutputPath: o.OutputPath, Write: o.Write, RenderMetadata: o.RenderMetadata, } } func (o *Options) Validate() error { if err := o.validateFormat(); err != nil { return err } return nil } func (o *Options) validateFormat() error { switch o.Format { case FormatHCL: return nil case FormatJSON: return nil default: return errors.New("invalid format: " + o.Format) } } ================================================ FILE: internal/cli/commands/render/render.go ================================================ // Package render provides the command to render the final terragrunt config in various formats. package render import ( "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" ) func Run(ctx context.Context, l log.Logger, opts *Options) error { if err := opts.Validate(); err != nil { return err } if opts.RunAll { return runAll(ctx, l, opts) } prepared, err := prepare.PrepareConfig(ctx, l, opts.TerragruntOptions) if err != nil { return err } return runRender(l, opts, prepared.Cfg) } func runAll(ctx context.Context, l log.Logger, opts *Options) error { d := discovery.NewDiscovery(opts.WorkingDir) components, err := d.Discover(ctx, l, opts.TerragruntOptions) if err != nil { return err } units := components.Filter(component.UnitKind).Sort() var errs []error for _, unit := range units { unitOpts := opts.Clone() unitOpts.WorkingDir = unit.Path() configFilename := config.DefaultTerragruntConfigPath if len(opts.TerragruntConfigPath) > 0 { configFilename = filepath.Base(opts.TerragruntConfigPath) } unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) prepared, err := prepare.PrepareConfig(ctx, l, unitOpts.TerragruntOptions) if err != nil { errs = append(errs, err) continue } if err := runRender(l, unitOpts, prepared.Cfg); err != nil { if opts.FailFast { return err } errs = append( errs, fmt.Errorf( "render of unit %s failed: %w", unit.Path(), err, ), ) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func runRender(l log.Logger, opts *Options, cfg *config.TerragruntConfig) error { if cfg == nil { return errors.New("terragrunt was not able to render the config because it received no config. This is almost certainly a bug in Terragrunt. Please open an issue on github.com/gruntwork-io/terragrunt with this message and the contents of your terragrunt.hcl") } switch opts.Format { case FormatJSON: return renderJSON(l, opts, cfg) case FormatHCL: return renderHCL(l, opts, cfg) default: return fmt.Errorf("unsupported render format: %s", opts.Format) } } func renderHCL(l log.Logger, opts *Options, cfg *config.TerragruntConfig) error { if opts.Write { buf := new(bytes.Buffer) _, err := cfg.WriteTo(buf) if err != nil { return err } return writeRendered(l, opts, buf.Bytes()) } l.Infof("Rendering config %s", opts.TerragruntConfigPath) _, err := cfg.WriteTo(opts.Writers.Writer) if err != nil { return err } return nil } func renderJSON(l log.Logger, opts *Options, cfg *config.TerragruntConfig) error { var terragruntConfigCty cty.Value if opts.RenderMetadata { cty, err := config.TerragruntConfigAsCtyWithMetadata(cfg) if err != nil { return err } terragruntConfigCty = cty } else { cty, err := config.TerragruntConfigAsCty(cfg) if err != nil { return err } terragruntConfigCty = cty } jsonBytes, err := marshalCtyValueJSONWithoutType(terragruntConfigCty) if err != nil { return err } if opts.Write { return writeRendered(l, opts, jsonBytes) } l.Infof("Rendering config %s", opts.TerragruntConfigPath) _, err = opts.Writers.Writer.Write(jsonBytes) if err != nil { return errors.New(err) } return nil } func writeRendered(l log.Logger, opts *Options, data []byte) error { outPath := opts.OutputPath if !filepath.IsAbs(outPath) { terragruntConfigDir := filepath.Dir(opts.TerragruntConfigPath) outPath = filepath.Join(terragruntConfigDir, outPath) } if err := util.EnsureDirectory(filepath.Dir(outPath)); err != nil { return err } l.Debugf("Rendering config %s to %s", opts.TerragruntConfigPath, outPath) const ownerWriteGlobalReadPerms = 0644 if err := os.WriteFile(outPath, data, ownerWriteGlobalReadPerms); err != nil { return errors.New(err) } return nil } // marshalCtyValueJSONWithoutType marshals the given cty.Value object into a JSON object that does not have the type. // Using ctyjson directly would render a json object with two attributes, "value" and "type", and this function returns // just the "value". // NOTE: We have to do two marshalling passes so that we can extract just the value. func marshalCtyValueJSONWithoutType(ctyVal cty.Value) ([]byte, error) { jsonBytesIntermediate, err := ctyjson.Marshal(ctyVal, cty.DynamicPseudoType) if err != nil { return nil, errors.New(err) } var ctyJSONOutput ctyhelper.CtyJSONOutput if err = json.Unmarshal(jsonBytesIntermediate, &ctyJSONOutput); err != nil { return nil, errors.New(err) } jsonBytes, err := json.Marshal(ctyJSONOutput.Value) if err != nil { return nil, errors.New(err) } jsonBytes = append(jsonBytes, '\n') return jsonBytes, nil } ================================================ FILE: internal/cli/commands/render/render_test.go ================================================ package render_test import ( "bytes" "encoding/json" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/render" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRenderJSON_Basic(t *testing.T) { t.Parallel() opts, _ := setupTest(t) var outputBuffer bytes.Buffer opts.Writers.Writer = &outputBuffer opts.Format = render.FormatJSON opts.RenderMetadata = false opts.Write = false err := render.Run(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) var result map[string]any err = json.Unmarshal(outputBuffer.Bytes(), &result) require.NoError(t, err) assert.NotNil(t, result) validateRenderedJSON(t, result, false) } func TestRenderJSON_WithMetadata(t *testing.T) { t.Parallel() opts, _ := setupTest(t) var outputBuffer bytes.Buffer opts.Writers.Writer = &outputBuffer opts.Format = render.FormatJSON opts.RenderMetadata = true opts.Write = false err := render.Run(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) var result map[string]any err = json.Unmarshal(outputBuffer.Bytes(), &result) require.NoError(t, err) assert.NotNil(t, result) validateRenderedJSON(t, result, true) } func TestRenderJSON_WriteToFile(t *testing.T) { t.Parallel() opts, _ := setupTest(t) outputPath := filepath.Join(helpers.TmpDirWOSymlinks(t), "output.json") opts.Format = render.FormatJSON opts.RenderMetadata = false opts.Write = true opts.OutputPath = outputPath err := render.Run(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) // Verify the file was created and contains valid JSON content, err := os.ReadFile(outputPath) require.NoError(t, err) var result map[string]any err = json.Unmarshal(content, &result) require.NoError(t, err) assert.NotNil(t, result) validateRenderedJSON(t, result, false) } func TestRenderJSON_InvalidFormat(t *testing.T) { t.Parallel() opts, _ := setupTest(t) opts.Format = "invalid" err := render.Run(t.Context(), logger.CreateLogger(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "invalid format") } func TestRenderJSON_HCLFormat(t *testing.T) { t.Parallel() opts, _ := setupTest(t) opts.Format = render.FormatHCL var renderedBuffer bytes.Buffer opts.Writers.Writer = &renderedBuffer err := render.Run(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) assert.Equal(t, testTerragruntConfigFixture, renderedBuffer.String()) } // setupTest creates a temporary directory with a terragrunt config file and returns the necessary test setup func setupTest(t *testing.T) (*render.Options, string) { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") err := os.WriteFile(configPath, []byte(testTerragruntConfigFixture), 0644) require.NoError(t, err) tgOptions, err := options.NewTerragruntOptionsForTest(configPath) require.NoError(t, err) return render.NewOptions(tgOptions), configPath } // validateRenderedJSON validates the common JSON structure and values func validateRenderedJSON(t *testing.T, result map[string]any, withMetadata bool) { t.Helper() inputs, ok := result["inputs"].(map[string]any) require.True(t, ok) stringInput := inputs["string_input"] if withMetadata { data, ok := stringInput.(map[string]any) require.True(t, ok) assert.NotNil(t, data) metadata, ok := data["metadata"].(map[string]any) require.True(t, ok) assert.NotNil(t, metadata) value, ok := data["value"].(string) require.True(t, ok) assert.Equal(t, "test", value) } else { assert.Equal(t, "test", stringInput) } numberInput := inputs["number_input"] if withMetadata { data, ok := numberInput.(map[string]any) require.True(t, ok) assert.NotNil(t, data) } else { assert.InEpsilon(t, float64(42), numberInput, 0.1) } boolInput := inputs["bool_input"] if withMetadata { data, ok := boolInput.(map[string]any) require.True(t, ok) assert.NotNil(t, data) } else { assert.Equal(t, true, boolInput) } listInput := inputs["list_input"] if withMetadata { data, ok := listInput.(map[string]any) require.True(t, ok) assert.NotNil(t, data) } else { assert.Equal(t, []any{"item1", "item2"}, listInput) } mapInput := inputs["map_input"] if withMetadata { data, ok := mapInput.(map[string]any) require.True(t, ok) assert.NotNil(t, data) } else { assert.Equal(t, map[string]any{"key": "value"}, mapInput) } } const testTerragruntConfigFixture = `terraform { source = "test" } inputs = { bool_input = true list_input = ["item1", "item2"] map_input = { key = "value" } number_input = 42 string_input = "test" } ` ================================================ FILE: internal/cli/commands/run/cli.go ================================================ // Package run contains the CLI command definition for interacting with OpenTofu/Terraform. package run import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/runner/graph" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "run" ) func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { cmdFlags := NewFlags(l, opts, nil) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil), shared.NewGraphFlag(opts, nil)) cmd := &clihelper.Command{ Name: CommandName, Usage: "Run an OpenTofu/Terraform command.", UsageText: "terragrunt run [options] -- ", Description: "Run a command, passing arguments to an orchestrated tofu/terraform binary.\n\nThis is the explicit, and most flexible form of running an IaC command with Terragrunt. Shortcuts can be found in \"terragrunt --help\" for common use-cases.", Examples: []string{ "# Run a plan\nterragrunt run -- plan\n# Shortcut:\n# terragrunt plan", "# Run output with -json flag\nterragrunt run -- output -json\n# Shortcut:\n# terragrunt output -json", }, Flags: cmdFlags, Subcommands: NewSubcommands(l, opts), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { tgOpts := opts.OptionsFromContext(ctx) if tgOpts.RunAll { return runall.Run(ctx, l, tgOpts) } if tgOpts.Graph { return graph.Run(ctx, l, tgOpts) } if len(cliCtx.Args()) == 0 { return clihelper.ShowCommandHelp(ctx, cliCtx) } return Action(l, opts)(ctx, cliCtx) }, } return cmd } func NewSubcommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { var subcommands = make(clihelper.Commands, len(tf.CommandNames)) for i, name := range tf.CommandNames { usage, visible := tf.CommandUsages[name] subcommand := &clihelper.Command{ Name: name, Usage: usage, Hidden: !visible, CustomHelp: ShowTFHelp(l, opts), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { return Action(l, opts)(ctx, cliCtx) }, } subcommands[i] = subcommand } return subcommands } func Action(l log.Logger, opts *options.TerragruntOptions) clihelper.ActionFunc { return func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts) } } ================================================ FILE: internal/cli/commands/run/flags.go ================================================ // Package run provides Terragrunt command flags. package run import ( "context" "fmt" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( NoAutoInitFlagName = "no-auto-init" NoAutoRetryFlagName = "no-auto-retry" NoAutoApproveFlagName = "no-auto-approve" NoAutoProviderCacheDirFlagName = "no-auto-provider-cache-dir" NoEngineFlagName = "no-engine" NoDependencyFetchOutputFromStateFlagName = "no-dependency-fetch-output-from-state" TFForwardStdoutFlagName = "tf-forward-stdout" UnitsThatIncludeFlagName = "units-that-include" DependencyFetchOutputFromStateFlagName = "dependency-fetch-output-from-state" UsePartialParseConfigCacheFlagName = "use-partial-parse-config-cache" SummaryPerUnitFlagName = "summary-per-unit" VersionManagerFileNameFlagName = "version-manager-file-name" DisableCommandValidationFlagName = "disable-command-validation" NoDestroyDependenciesCheckFlagName = "no-destroy-dependencies-check" DestroyDependenciesCheckFlagName = "destroy-dependencies-check" SourceFlagName = "source" SourceMapFlagName = "source-map" SourceUpdateFlagName = "source-update" NoStackGenerate = "no-stack-generate" // Terragrunt Provider Cache related flags. ProviderCacheFlagName = "provider-cache" ProviderCacheDirFlagName = "provider-cache-dir" ProviderCacheHostnameFlagName = "provider-cache-hostname" ProviderCachePortFlagName = "provider-cache-port" ProviderCacheTokenFlagName = "provider-cache-token" ProviderCacheRegistryNamesFlagName = "provider-cache-registry-names" // Engine related environment variables. EngineEnableFlagName = "experimental-engine" EngineCachePathFlagName = "engine-cache-path" EngineSkipCheckFlagName = "engine-skip-check" EngineLogLevelFlagName = "engine-log-level" // Report related flags. SummaryDisableFlagName = "summary-disable" ReportFileFlagName = "report-file" ReportFormatFlagName = "report-format" ReportSchemaFlagName = "report-schema-file" // `--all` related flags. OutDirFlagName = "out-dir" JSONOutDirFlagName = "json-out-dir" // `--graph` related flags. GraphRootFlagName = "graph-root" // Config and download flags - use shared package constants ConfigFlagName = shared.ConfigFlagName // Auth and IAM flags - use shared package constants InputsDebugFlagName = shared.InputsDebugFlagName IAMAssumeRoleFlagName = shared.IAMAssumeRoleFlagName IAMAssumeRoleDurationFlagName = shared.IAMAssumeRoleDurationFlagName IAMAssumeRoleSessionNameFlagName = shared.IAMAssumeRoleSessionNameFlagName IAMAssumeRoleWebIdentityTokenFlagName = shared.IAMAssumeRoleWebIdentityTokenFlagName ) // NewFlags creates and returns global flags. func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := flags.Prefix{flags.TgPrefix} terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName) legacyLogsControl := flags.StrictControlsByCommand(opts.StrictControls, CommandName, controls.LegacyLogs) cmdFlags := clihelper.Flags{ // `--all` related flags. flags.NewFlag(&clihelper.GenericFlag[string]{ Name: OutDirFlagName, EnvVars: tgPrefix.EnvVars(OutDirFlagName), Destination: &opts.OutputFolder, Usage: "Directory to store plan files.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("out-dir"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: JSONOutDirFlagName, EnvVars: tgPrefix.EnvVars(JSONOutDirFlagName), Destination: &opts.JSONOutputFolder, Usage: "Directory to store json plan files.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("json-out-dir"), terragruntPrefixControl)), // `graph/-graph` related flags. flags.NewFlag(&clihelper.GenericFlag[string]{ Name: GraphRootFlagName, EnvVars: tgPrefix.EnvVars(GraphRootFlagName), Destination: &opts.GraphRoot, Usage: "Root directory from where to build graph dependencies.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("graph-root"), terragruntPrefixControl)), // `--all` and `--graph` related flags. flags.NewFlag(&clihelper.BoolFlag{ Name: NoStackGenerate, EnvVars: tgPrefix.EnvVars(NoStackGenerate), Destination: &opts.NoStackGenerate, Usage: "Disable automatic stack regeneration before running the command.", }), // Backward compatibility with `terragrunt-` prefix flags. shared.NewConfigFlag(opts, prefix, CommandName), shared.NewTFPathFlag(opts), flags.NewFlag(&clihelper.BoolFlag{ Name: NoAutoInitFlagName, EnvVars: tgPrefix.EnvVars(NoAutoInitFlagName), Usage: "Don't automatically run 'terraform/tofu init' during other terragrunt commands. You must run 'terragrunt init' manually.", Negative: true, Destination: &opts.AutoInit, }, flags.WithDeprecatedFlag(&clihelper.BoolFlag{ EnvVars: terragruntPrefix.EnvVars("auto-init"), }, nil, terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoAutoRetryFlagName, EnvVars: tgPrefix.EnvVars(NoAutoRetryFlagName), Destination: &opts.AutoRetry, Usage: "Don't automatically re-run command in case of transient errors.", Negative: true, }, flags.WithDeprecatedFlag(&clihelper.BoolFlag{ EnvVars: terragruntPrefix.EnvVars("auto-retry"), }, nil, terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoAutoApproveFlagName, EnvVars: tgPrefix.EnvVars(NoAutoApproveFlagName), Destination: &opts.RunAllAutoApprove, Usage: "Don't automatically append '-auto-approve' to the underlying OpenTofu/Terraform commands run with 'run --all'.", Negative: true, }, flags.WithDeprecatedFlag(&clihelper.BoolFlag{ EnvVars: terragruntPrefix.EnvVars("auto-approve"), }, nil, terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoAutoProviderCacheDirFlagName, EnvVars: tgPrefix.EnvVars(NoAutoProviderCacheDirFlagName), Destination: &opts.NoAutoProviderCacheDir, Usage: "Disable the auto-provider-cache-dir feature even when the experiment is enabled.", }), shared.NewDownloadDirFlag(opts, prefix, CommandName), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: SourceFlagName, EnvVars: tgPrefix.EnvVars(SourceFlagName), Destination: &opts.Source, Usage: "Download OpenTofu/Terraform configurations from the specified source into a temporary folder, and run Terraform in that temporary folder.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("source"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: SourceUpdateFlagName, EnvVars: tgPrefix.EnvVars(SourceUpdateFlagName), Destination: &opts.SourceUpdate, Usage: "Delete the contents of the temporary folder to clear out any old, cached source code before downloading new source code into it.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("source-update"), terragruntPrefixControl)), flags.NewFlag(&clihelper.MapFlag[string, string]{ Name: SourceMapFlagName, EnvVars: tgPrefix.EnvVars(SourceMapFlagName), Destination: &opts.SourceMap, Usage: "Replace any source URL (including the source URL of a config pulled in with dependency blocks) that has root source with dest.", Splitter: util.SplitUrls, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("source-map"), terragruntPrefixControl)), // Assume IAM Role flags. shared.NewInputsDebugFlag(opts, prefix, CommandName), flags.NewFlag(&clihelper.BoolFlag{ Name: UsePartialParseConfigCacheFlagName, EnvVars: tgPrefix.EnvVars(UsePartialParseConfigCacheFlagName), Destination: &opts.UsePartialParseConfigCache, Usage: "Enables caching of includes during partial parsing operations. Will also be used for the --iam-role option if provided.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("use-partial-parse-config-cache"), terragruntPrefixControl)), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: VersionManagerFileNameFlagName, EnvVars: tgPrefix.EnvVars(VersionManagerFileNameFlagName), Destination: &opts.VersionManagerFileName, Usage: "File names used during the computation of the cache key for the version manager files.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: DependencyFetchOutputFromStateFlagName, EnvVars: tgPrefix.EnvVars(DependencyFetchOutputFromStateFlagName), Usage: "Enable the dependency-fetch-output-from-state experiment to fetch dependency output directly from the state file instead of using tofu/terraform output.", Action: func(_ context.Context, _ *clihelper.Context, val bool) error { if val { return opts.Experiments.EnableExperiment(experiment.DependencyFetchOutputFromState) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("fetch-dependency-output-from-state"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoDependencyFetchOutputFromStateFlagName, EnvVars: tgPrefix.EnvVars(NoDependencyFetchOutputFromStateFlagName), Destination: &opts.NoDependencyFetchOutputFromState, Usage: "Disable the dependency-fetch-output-from-state feature even when the experiment is enabled.", Hidden: true, }), flags.NewFlag(&clihelper.BoolFlag{ Name: TFForwardStdoutFlagName, EnvVars: tgPrefix.EnvVars(TFForwardStdoutFlagName), Destination: &opts.ForwardTFStdout, Usage: "If specified, the output of OpenTofu/Terraform commands will be printed as is, without being integrated into the Terragrunt log.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("forward-tf-stdout"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("include-module-prefix"), legacyLogsControl)), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: UnitsThatIncludeFlagName, EnvVars: tgPrefix.EnvVars(UnitsThatIncludeFlagName), Usage: "If flag is set, 'run --all' will only run the command against Terragrunt modules that include the specified file.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value []string) error { if len(value) != 0 { if err := opts.StrictControls.FilterByNames(controls.UnitsThatInclude).Evaluate(ctx); err != nil { return err } for _, v := range value { attrExpr, err := filter.NewAttributeExpression(filter.AttributeReading, v) if err != nil { return err } opts.Filters = append(opts.Filters, filter.NewFilter(attrExpr, attrExpr.String())) } return nil } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("modules-that-include"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: DisableCommandValidationFlagName, EnvVars: tgPrefix.EnvVars(DisableCommandValidationFlagName), Destination: &opts.DisableCommandValidation, Usage: "When this flag is set, Terragrunt will not validate the tofu/terraform command.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { return opts.StrictControls.FilterByNames(controls.DisableCommandValidation).Evaluate(ctx) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("disable-command-validation"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoDestroyDependenciesCheckFlagName, EnvVars: tgPrefix.EnvVars(NoDestroyDependenciesCheckFlagName), Usage: "When this flag is set, Terragrunt will not check for dependent units when destroying.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { return opts.StrictControls.FilterByNames(controls.NoDestroyDependenciesCheck).Evaluate(ctx) } return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: DestroyDependenciesCheckFlagName, EnvVars: tgPrefix.EnvVars(DestroyDependenciesCheckFlagName), Destination: &opts.DestroyDependenciesCheck, Usage: "When this flag is set, Terragrunt will check for dependent units when destroying.", }), // Terragrunt Provider Cache flags. flags.NewFlag(&clihelper.BoolFlag{ Name: ProviderCacheFlagName, EnvVars: tgPrefix.EnvVars(ProviderCacheFlagName), Destination: &opts.ProviderCacheOptions.Enabled, Usage: "Enables Terragrunt's provider caching.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ProviderCacheDirFlagName, EnvVars: tgPrefix.EnvVars(ProviderCacheDirFlagName), Destination: &opts.ProviderCacheOptions.Dir, Usage: "The path to the Terragrunt provider cache directory. By default, 'terragrunt/providers' folder in the user cache directory.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache-dir"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ProviderCacheTokenFlagName, EnvVars: tgPrefix.EnvVars(ProviderCacheTokenFlagName), Destination: &opts.ProviderCacheOptions.Token, Usage: "The token for authentication to the Terragrunt Provider Cache server. By default, assigned automatically.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache-token"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ProviderCacheHostnameFlagName, EnvVars: tgPrefix.EnvVars(ProviderCacheHostnameFlagName), Destination: &opts.ProviderCacheOptions.Hostname, Usage: "The hostname of the Terragrunt Provider Cache server. By default, 'localhost'.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache-hostname"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[int]{ Name: ProviderCachePortFlagName, EnvVars: tgPrefix.EnvVars(ProviderCachePortFlagName), Destination: &opts.ProviderCacheOptions.Port, Usage: "The port of the Terragrunt Provider Cache server. By default, assigned automatically.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache-port"), terragruntPrefixControl)), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: ProviderCacheRegistryNamesFlagName, EnvVars: tgPrefix.EnvVars(ProviderCacheRegistryNamesFlagName), Destination: &opts.ProviderCacheOptions.RegistryNames, Usage: "The list of remote registries to cached by Terragrunt Provider Cache server. By default, 'registry.terraform.io', 'registry.opentofu.org'.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("provider-cache-registry-names"), terragruntPrefixControl)), shared.NewAuthProviderCmdFlag(opts, prefix, CommandName), // Terragrunt engine flags. flags.NewFlag(&clihelper.BoolFlag{ Name: EngineEnableFlagName, EnvVars: tgPrefix.EnvVars(EngineEnableFlagName), Usage: "Enable the iac-engine experiment to use IaC engines.", Hidden: true, Action: func(_ context.Context, _ *clihelper.Context, val bool) error { if val { return opts.Experiments.EnableExperiment(experiment.IacEngine) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("experimental-engine"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: EngineCachePathFlagName, EnvVars: tgPrefix.EnvVars(EngineCachePathFlagName), Destination: &opts.EngineOptions.CachePath, Usage: "Cache path for Terragrunt engine files.", Hidden: true, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("engine-cache-path"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: EngineSkipCheckFlagName, EnvVars: tgPrefix.EnvVars(EngineSkipCheckFlagName), Destination: &opts.EngineOptions.SkipChecksumCheck, Usage: "Skip checksum check for Terragrunt engine files.", Hidden: true, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("engine-skip-check"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: EngineLogLevelFlagName, EnvVars: tgPrefix.EnvVars(EngineLogLevelFlagName), Destination: &opts.EngineOptions.LogLevel, Usage: "Terragrunt engine log level.", Hidden: true, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("engine-log-level"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoEngineFlagName, EnvVars: tgPrefix.EnvVars(NoEngineFlagName), Destination: &opts.EngineOptions.NoEngine, Usage: "Disable IaC engines even when the iac-engine experiment is enabled.", Hidden: true, }), flags.NewFlag(&clihelper.BoolFlag{ Name: SummaryDisableFlagName, EnvVars: tgPrefix.EnvVars(SummaryDisableFlagName), Destination: &opts.SummaryDisable, Usage: `Disable the summary output at the end of a run.`, }), flags.NewFlag(&clihelper.BoolFlag{ Name: SummaryPerUnitFlagName, EnvVars: tgPrefix.EnvVars(SummaryPerUnitFlagName), Destination: &opts.SummaryPerUnit, Usage: `Show duration information for each unit in the summary output.`, }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ReportFileFlagName, EnvVars: tgPrefix.EnvVars(ReportFileFlagName), Usage: `Path to generate report file in.`, Setter: func(value string) error { if value == "" { return nil } opts.ReportFile = value ext := filepath.Ext(value) if ext == "" { ext = ".csv" } if ext != ".csv" && ext != ".json" { return nil } if opts.ReportFormat == "" { opts.ReportFormat = report.Format(ext[1:]) } return nil }, }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ReportFormatFlagName, EnvVars: tgPrefix.EnvVars(ReportFormatFlagName), Usage: `Format of the report file.`, Setter: func(value string) error { if value == "" && opts.ReportFormat == "" { opts.ReportFormat = report.FormatCSV return nil } opts.ReportFormat = report.Format(value) switch opts.ReportFormat { case report.FormatCSV: case report.FormatJSON: default: return fmt.Errorf("unsupported report format: %s", value) } return nil }, }), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: ReportSchemaFlagName, EnvVars: tgPrefix.EnvVars(ReportSchemaFlagName), Usage: `Path to generate report schema file in.`, Destination: &opts.ReportSchemaFile, }), } // Add shared flags cmdFlags = cmdFlags.Add(shared.NewBackendFlags(opts, prefix)...) cmdFlags = cmdFlags.Add(shared.NewFeatureFlags(opts, prefix)...) cmdFlags = cmdFlags.Add(shared.NewFailFastFlag(opts)) cmdFlags = cmdFlags.Add(shared.NewIAMAssumeRoleFlags(opts, prefix, CommandName)...) cmdFlags = cmdFlags.Add(shared.NewQueueFlags(opts, prefix)...) cmdFlags = cmdFlags.Add(shared.NewFilterFlags(l, opts)...) cmdFlags = cmdFlags.Add(shared.NewParallelismFlag(opts)) return cmdFlags.Sort() } ================================================ FILE: internal/cli/commands/run/help.go ================================================ package run import ( "context" "fmt" "io" "strings" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // TFCommandHelpTemplate is the TF command CLI help template. const TFCommandHelpTemplate = `Usage: {{ if .Command.UsageText }}{{ wrap .Command.UsageText 3 }}{{ else }}{{ range $parent := parentCommands . }}{{ $parent.HelpName }} {{ end }}[global options] {{ .Command.HelpName }} [options]{{ if eq .Command.Name "` + tf.CommandNameApply + `" }} [PLAN]{{ end }}{{ end }}{{ $description := .Command.Usage }}{{ if .Command.Description }}{{ $description = .Command.Description }}{{ end }}{{ if $description }} {{ wrap $description 3 }}{{ end }}{{ if ne .Parent.Command.Name "` + CommandName + `" }} This is a shortcut for the command ` + "`terragrunt " + CommandName + "`" + `.{{ end }} It wraps the ` + "`{{ tfCommand }}`" + ` command of the binary defined by ` + "`tf-path`" + `. {{ if isTerraformPath }}Terraform{{ else }}OpenTofu{{ end }} ` + "`{{ tfCommand }}`" + ` help:{{ $tfHelp := runTFHelp }}{{ if $tfHelp }} {{ $tfHelp }}{{ end }} ` // ShowTFHelp prints TF help for the given `cliCtx.Command` command. func ShowTFHelp(l log.Logger, opts *options.TerragruntOptions) clihelper.HelpFunc { return func(ctx context.Context, cliCtx *clihelper.Context) error { if err := shared.NewTFPathFlag(opts).Parse(cliCtx.Args()); err != nil { return err } clihelper.HelpPrinterCustom(cliCtx, TFCommandHelpTemplate, map[string]any{ "isTerraformPath": func() bool { return isTerraformPath(opts) }, "runTFHelp": func() string { return runTFHelp(ctx, cliCtx, l, opts) }, "tfCommand": func() string { return cliCtx.Command.Name }, }) return nil } } func runTFHelp(ctx context.Context, cliCtx *clihelper.Context, l log.Logger, opts *options.TerragruntOptions) string { opts = opts.Clone() opts.Writers.Writer = io.Discard terraformHelpCmd := []string{tf.FlagNameHelpLong, cliCtx.Command.Name} out, err := tf.RunCommandWithOutput(ctx, l, configbridge.TFRunOptsFromOpts(opts), terraformHelpCmd...) if err != nil { var processError util.ProcessExecutionError if ok := errors.As(err, &processError); ok { err = processError.Err } return fmt.Sprintf("Failed to execute \"%s %s\": %s", opts.TFPath, strings.Join(terraformHelpCmd, " "), err.Error()) } result := out.Stdout.String() lines := strings.Split(result, "\n") // Trim first empty lines or that has prefix "Usage:". for i := range lines { if strings.TrimSpace(lines[i]) == "" || strings.HasPrefix(lines[i], "Usage:") { continue } return strings.Join(lines[i:], "\n") } return result } ================================================ FILE: internal/cli/commands/run/run.go ================================================ package run import ( "context" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/os/stdout" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/graph" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Run runs the run command. func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.TerraformCommand == tf.CommandNameDestroy { opts.CheckDependentUnits = opts.DestroyDependenciesCheck } r := report.NewReport().WithWorkingDir(opts.WorkingDir) // Configure report colors. // // This doesn't actually do anything for single-unit runs, but it's // helpful to leave it in here for consistency, if we ever add // support for run summaries in single-unit runs. if l.Formatter().DisabledColors() || stdout.IsRedirected() { r.WithDisableColor() } if opts.ReportFormat != "" { r.WithFormat(opts.ReportFormat) } tgOpts := opts.OptionsFromContext(ctx) if tgOpts.RunAll { return runall.Run(ctx, l, tgOpts) } if tgOpts.Graph { return graph.Run(ctx, l, tgOpts) } if opts.ReportSchemaFile != "" { defer r.WriteSchemaToFile(opts.ReportSchemaFile) //nolint:errcheck } if opts.ReportFile != "" { defer r.WriteToFile(opts.ReportFile) //nolint:errcheck } if opts.TerraformCommand == "" { return errors.New(run.MissingCommand{}) } // Early exit for version command to avoid expensive setup if opts.TerraformCommand == tf.CommandNameVersion { return runVersionCommand(ctx, l, opts) } // We need to get the credentials from auth-provider-cmd at the very beginning, // since the locals block may contain `get_aws_account_id()` func. credsGetter := creds.NewGetter() if err := credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, opts.Env, externalcmd.NewProvider(l, opts.AuthProviderCmd, configbridge.ShellRunOptsFromOpts(opts)), ); err != nil { return err } l, err := checkVersionConstraints(ctx, l, opts) if err != nil { return err } parseCtx, pctx := configbridge.NewParsingContext(ctx, l, opts) cfg, err := config.ReadTerragruntConfig(parseCtx, l, pctx, pctx.ParserOptions) if err != nil { return err } if opts.CheckDependentUnits { allowDestroy := confirmActionWithDependentUnits(ctx, l, opts, cfg) if !allowDestroy { return nil } } runCfg := cfg.ToRunConfig(l) unitPath := filepath.Clean(opts.RootWorkingDir) if _, err := r.EnsureRun(l, unitPath); err != nil { return err } var runErr error defer func() { if runErr != nil { if endErr := r.EndRun( l, unitPath, report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError), report.WithCauseRunError(runErr.Error()), ); endErr != nil { l.Errorf("Error ending run for unit %s: %v", unitPath, endErr) } return } if endErr := r.EndRun( l, unitPath, report.WithResult(report.ResultSucceeded), ); endErr != nil { l.Errorf("Error ending run for unit %s: %v", unitPath, endErr) } }() runErr = run.Run(ctx, l, configbridge.NewRunOptions(tgOpts), r, runCfg, credsGetter) return runErr } // isTerraformPath returns true if the TFPath ends with the default Terraform path. // This is used by help.go to determine whether to show "Terraform" or "OpenTofu" in help text. func isTerraformPath(opts *options.TerragruntOptions) bool { return strings.HasSuffix(opts.TFPath, options.TerraformDefaultPath) } // runVersionCommand runs the version command. We do this instead of going through the normal run flow because // we can resolve `version` a lot more cheaply. func runVersionCommand(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if !opts.TFPathExplicitlySet { if tfPath, err := getTFPathFromConfig(ctx, l, opts); err != nil { return err } else if tfPath != "" { opts.TFPath = tfPath } } return tf.RunCommand(ctx, l, configbridge.TFRunOptsFromOpts(opts), opts.TerraformCliArgs.Slice()...) } func getTFPathFromConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (string, error) { if !util.FileExists(opts.TerragruntConfigPath) { l.Debugf("Did not find the config file %s", opts.TerragruntConfigPath) return "", nil } cfg, err := getTerragruntConfig(ctx, l, opts) if err != nil { return "", err } return cfg.TerraformBinary, nil } // CheckVersionConstraints checks the version constraints of both terragrunt and terraform. // Note that as a side effect this will set the following settings on terragruntOptions: // - TerraformPath // - TerraformVersion // - FeatureFlags // TODO: Look into a way to refactor this function to avoid the side effect. func checkVersionConstraints(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (log.Logger, error) { partialTerragruntConfig, err := getTerragruntConfig(ctx, l, opts) if err != nil { return l, err } // If the TFPath is not explicitly set, use the TFPath from the config if it is set. if !opts.TFPathExplicitlySet && partialTerragruntConfig.TerraformBinary != "" { opts.TFPath = partialTerragruntConfig.TerraformBinary } l, ver, impl, err := run.PopulateTFVersion(ctx, l, opts.WorkingDir, opts.VersionManagerFileName, configbridge.TFRunOptsFromOpts(opts)) if err != nil { return l, err } opts.TerraformVersion = ver opts.TofuImplementation = impl terraformVersionConstraint := run.DefaultTerraformVersionConstraint if partialTerragruntConfig.TerraformVersionConstraint != "" { terraformVersionConstraint = partialTerragruntConfig.TerraformVersionConstraint } if err := run.CheckTerraformVersionMeetsConstraint(opts.TerraformVersion, terraformVersionConstraint); err != nil { return l, err } if partialTerragruntConfig.TerragruntVersionConstraint != "" { if err := run.CheckTerragruntVersionMeetsConstraint(opts.TerragruntVersion, partialTerragruntConfig.TerragruntVersionConstraint); err != nil { return l, err } } if partialTerragruntConfig.FeatureFlags != nil { // update feature flags for evaluation for _, flag := range partialTerragruntConfig.FeatureFlags { flagName := flag.Name defaultValue, err := flag.DefaultAsString() if err != nil { return l, err } if _, exists := opts.FeatureFlags.Load(flagName); !exists { opts.FeatureFlags.Store(flagName, defaultValue) } } } return l, nil } func getTerragruntConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (*config.TerragruntConfig, error) { ctx, configCtx := configbridge.NewParsingContext(ctx, l, opts) configCtx = configCtx.WithDecodeList( config.TerragruntVersionConstraints, config.FeatureFlagsBlock, ) return config.PartialParseConfigFile( ctx, configCtx, l, opts.TerragruntConfigPath, nil, ) } // confirmActionWithDependentUnits - Show warning with list of dependent modules from current module before destroy func confirmActionWithDependentUnits( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) bool { units := findDependentUnits(ctx, l, opts, cfg) if len(units) != 0 { if _, err := opts.Writers.ErrWriter.Write([]byte("Detected dependent units:\n")); err != nil { l.Error(err) return false } for _, unit := range units { if _, err := opts.Writers.ErrWriter.Write([]byte(unit + "\n")); err != nil { l.Error(err) return false } } prompt := "WARNING: Are you sure you want to continue?" shouldRun, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { l.Error(err) return false } return shouldRun } return true } // findDependentUnits finds dependent units for the given unit, and returns their paths. func findDependentUnits( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) []string { units := runner.FindDependentUnits(ctx, l, opts, cfg) paths := make([]string, len(units)) for i, unit := range units { paths[i] = unit.Path() } return paths } ================================================ FILE: internal/cli/commands/scaffold/cli.go ================================================ // Package scaffold provides the command to scaffold a new Terragrunt module. package scaffold import ( "context" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( CommandName = "scaffold" OutputFolderFlagName = "output-folder" VarFlagName = "var" VarFileFlagName = "var-file" NoDependencyPrompt = "no-dependency-prompt" ) func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) // Start with shared scaffolding flags scaffoldFlags := shared.NewScaffoldingFlags(opts, prefix) // Add scaffold-specific flags scaffoldFlags = append(scaffoldFlags, flags.NewFlag(&clihelper.GenericFlag[string]{ Name: OutputFolderFlagName, Destination: &opts.ScaffoldOutputFolder, Usage: "Output folder for scaffold output.", }), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: VarFlagName, EnvVars: tgPrefix.EnvVars(VarFlagName), Destination: &opts.ScaffoldVars, Usage: "Variables for usage in scaffolding.", }), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: VarFileFlagName, EnvVars: tgPrefix.EnvVars(VarFileFlagName), Destination: &opts.ScaffoldVarFiles, Usage: "Files with variables to be used in unit scaffolding.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoDependencyPrompt, EnvVars: tgPrefix.EnvVars(NoDependencyPrompt), Destination: &opts.NoDependencyPrompt, Usage: "Do not prompt for confirmation to include dependencies.", }), ) return scaffoldFlags } func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { flags := NewFlags(opts, nil) // Accept backend and feature flags for scaffold as well flags = append(flags, shared.NewBackendFlags(opts, nil)...) flags = append(flags, shared.NewFeatureFlags(opts, nil)...) return &clihelper.Command{ Name: CommandName, Usage: "Scaffold a new Terragrunt module.", Flags: flags, Action: func(ctx context.Context, cliCtx *clihelper.Context) error { var moduleURL, templateURL string if val := cliCtx.Args().Get(0); val != "" { moduleURL = val } if val := cliCtx.Args().Get(1); val != "" { templateURL = val } if opts.ScaffoldRootFileName == "" { opts.ScaffoldRootFileName = GetDefaultRootFileName(ctx, opts) } return Run(ctx, l, opts.OptionsFromContext(ctx), moduleURL, templateURL) }, } } func GetDefaultRootFileName(ctx context.Context, opts *options.TerragruntOptions) string { if err := opts.StrictControls.FilterByNames(controls.RootTerragruntHCL).SuppressWarning().Evaluate(ctx); err != nil { return config.RecommendedParentConfigName } // Check to see if you can find the recommended parent config name first, // if a user has it defined, go ahead and use it. dir := opts.WorkingDir prevDir := "" for foldersToCheck := opts.MaxFoldersToCheck; dir != prevDir && dir != "" && foldersToCheck > 0; foldersToCheck-- { prevDir = dir _, err := os.Stat(filepath.Join(dir, config.RecommendedParentConfigName)) if err == nil { return config.RecommendedParentConfigName } dir = filepath.Dir(dir) } return config.DefaultTerragruntConfigPath } ================================================ FILE: internal/cli/commands/scaffold/scaffold.go ================================================ package scaffold import ( "context" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" boilerplate_options "github.com/gruntwork-io/boilerplate/options" "github.com/gruntwork-io/boilerplate/templates" "github.com/gruntwork-io/boilerplate/variables" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/hashicorp/go-getter/v2" ) const ( sourceURLTypeHTTPS = "git-https" sourceURLTypeGit = "git-ssh" sourceGitSSHUser = "git" sourceURLTypeVar = "SourceUrlType" sourceGitSSHUserVar = "SourceGitSshUser" refVar = "Ref" // refParam - ?ref param from url refParam = "ref" moduleURLPattern = `(?:git|hg|s3|gcs)::([^:]+)://([^/]+)(/.*)` moduleURLParts = 4 // TODO: Make the root configuration file name configurable DefaultBoilerplateConfig = ` variables: - name: EnableRootInclude description: Should include root module type: bool default: true - name: RootFileName description: Name of the root Terragrunt configuration file type: string ` DefaultTerragruntTemplate = ` # This is a Terragrunt unit generated by Gruntwork Boilerplate (https://github.com/gruntwork-io/boilerplate). terraform { source = "{{ .sourceUrl }}" } {{ if .EnableRootInclude }} include "root" { path = find_in_parent_folders("{{ .RootFileName }}") } {{ end }} inputs = { # -------------------------------------------------------------------------------------------------------------------- # Required input variables # -------------------------------------------------------------------------------------------------------------------- {{ range .requiredVariables }} {{- if eq 1 (regexSplit "\n" .Description -1 | len ) }} # Description: {{ .Description }} {{- else }} # Description: {{- range $line := regexSplit "\n" .Description -1 }} # {{ $line | indent 2 }} {{- end }} {{- end }} # Type: {{ .Type }} {{ .Name }} = {{ .DefaultValuePlaceholder }} # TODO: fill in value {{ end }} # -------------------------------------------------------------------------------------------------------------------- # Optional input variables # Uncomment the ones you wish to set # -------------------------------------------------------------------------------------------------------------------- {{ range .optionalVariables }} {{- if eq 1 (regexSplit "\n" .Description -1 | len ) }} # Description: {{ .Description }} {{- else }} # Description: {{- range $line := regexSplit "\n" .Description -1 }} # {{ $line | indent 2 }} {{- end }} {{- end }} # Type: {{ .Type }} # {{ .Name }} = {{ .DefaultValue }} {{ end }} } ` ) var moduleURLRegex = regexp.MustCompile(moduleURLPattern) const ( enableRootInclude = "EnableRootInclude" rootFileName = "RootFileName" ) // NewBoilerplateOptions creates a new BoilerplateOptions struct func NewBoilerplateOptions( templateFolder, outputFolder string, vars map[string]any, terragruntOpts *options.TerragruntOptions, ) *boilerplate_options.BoilerplateOptions { return &boilerplate_options.BoilerplateOptions{ TemplateFolder: templateFolder, OutputFolder: outputFolder, OnMissingKey: boilerplate_options.DefaultMissingKeyAction, OnMissingConfig: boilerplate_options.DefaultMissingConfigAction, Vars: vars, ShellCommandAnswers: map[string]bool{}, NoShell: terragruntOpts.NoShell, NoHooks: terragruntOpts.NoHooks, NonInteractive: terragruntOpts.NonInteractive, DisableDependencyPrompt: terragruntOpts.NoDependencyPrompt, } } func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, moduleURL, templateURL string) error { // Apply catalog configuration settings, with CLI flags taking precedence applyCatalogConfigToScaffold(ctx, l, opts) // download remote repo to local dirsToClean := make([]string, 0, 1) // clean all temp dirs defer func() { for _, dir := range dirsToClean { if err := os.RemoveAll(dir); err != nil { l.Warnf("Failed to clean up dir %s: %v", dir, err) } } }() outputDir := opts.ScaffoldOutputFolder if outputDir == "" { outputDir = opts.WorkingDir } // scaffold only in empty directories if empty, err := util.IsDirectoryEmpty(opts.WorkingDir); !empty || err != nil { if err != nil { return err } l.Warnf("The working directory %s is not empty.", opts.WorkingDir) } if moduleURL == "" { return errors.New(NoModuleURLPassed{}) } // create temporary directory where to download module tempDir, err := os.MkdirTemp("", "scaffold") if err != nil { return errors.New(err) } dirsToClean = append(dirsToClean, tempDir) // prepare variables vars, err := variables.ParseVars(opts.ScaffoldVars, opts.ScaffoldVarFiles) if err != nil { return errors.New(err) } // parse module url moduleURL, err = parseModuleURL(ctx, l, opts, vars, moduleURL) if err != nil { return errors.New(err) } l.Infof("Scaffolding a new Terragrunt module %s to %s", moduleURL, outputDir) if _, err := getter.GetAny(ctx, tempDir, moduleURL); err != nil { return errors.New(err) } // extract variables from downloaded module requiredVariables, optionalVariables, err := parseVariables(l, opts, tempDir) if err != nil { return errors.New(err) } l.Debugf("Parsed %d required variables and %d optional variables", len(requiredVariables), len(optionalVariables)) // prepare boilerplate files to render Terragrunt files boilerplateDir, err := prepareBoilerplateFiles(ctx, l, opts, templateURL, tempDir) if err != nil { return errors.New(err) } // add additional variables vars["requiredVariables"] = requiredVariables vars["optionalVariables"] = optionalVariables vars["sourceUrl"] = moduleURL // Only set these if the `vars` map doesn't already have them set if _, found := vars[enableRootInclude]; !found { vars[enableRootInclude] = !opts.ScaffoldNoIncludeRoot } else { l.Warnf( "The %s variable is already set in the var flag(s). The --%s flag will be ignored.", enableRootInclude, shared.NoIncludeRootFlagName, ) } if _, found := vars[rootFileName]; !found { vars[rootFileName] = opts.ScaffoldRootFileName } else { l.Warnf( "The %s variable is already set in the var flag(s). The --%s flag will be ignored.", rootFileName, shared.NoIncludeRootFlagName, ) } l.Infof("Running boilerplate generation to %s", outputDir) boilerplateOpts := NewBoilerplateOptions(boilerplateDir, outputDir, vars, opts) emptyDep := variables.Dependency{} if err := templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep); err != nil { return errors.New(err) } l.Infof("Running fmt on generated code %s", outputDir) if err := format.Run(ctx, l, opts); err != nil { return errors.New(err) } l.Info("Scaffolding completed") return nil } // applyCatalogConfigToScaffold applies catalog configuration settings to scaffold options. // CLI flags take precedence over config file settings. func applyCatalogConfigToScaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) { _, pctx := configbridge.NewParsingContext(ctx, l, opts) catalogCfg, err := config.ReadCatalogConfig(ctx, l, pctx) if err != nil { // Don't fail if catalog config can't be read - it's optional l.Debugf("Could not read catalog config for scaffold: %v", err) return } if catalogCfg == nil { return } // Apply config settings only if CLI flags weren't explicitly set // Since both NoShell and NoHooks default to false, we apply the config value // only if it's true (enabling the restriction) if catalogCfg.NoShell != nil && *catalogCfg.NoShell && !opts.NoShell { l.Debugf("Applying catalog config: no_shell = true") opts.NoShell = true } if catalogCfg.NoHooks != nil && *catalogCfg.NoHooks && !opts.NoHooks { l.Debugf("Applying catalog config: no_hooks = true") opts.NoHooks = true } } // generateDefaultTemplate - write default template to provided dir func generateDefaultTemplate(boilerplateDir string) (string, error) { const ownerWriteGlobalReadPerms = 0644 if err := os.WriteFile( filepath.Join( boilerplateDir, config.DefaultTerragruntConfigPath, ), []byte(DefaultTerragruntTemplate), ownerWriteGlobalReadPerms, ); err != nil { return "", errors.New(err) } if err := os.WriteFile( filepath.Join( boilerplateDir, "boilerplate.yml", ), []byte(DefaultBoilerplateConfig), ownerWriteGlobalReadPerms, ); err != nil { return "", errors.New(err) } return boilerplateDir, nil } // downloadTemplate - parse URL, download files, and handle subfolders func downloadTemplate( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, templateURL, tempDir string, ) (string, error) { parsedTemplateURL, err := tf.ToSourceURL(templateURL, tempDir) if err != nil { return "", errors.New(err) } // Split the processed URL to get the base URL and subfolder baseURL, subFolder, err := tf.SplitSourceURL(l, parsedTemplateURL) if err != nil { return "", errors.New(err) } // Go-getter expects a pathspec or . for file paths if baseURL.Scheme == "" || baseURL.Scheme == "file" { baseURL.Path = filepath.ToSlash(strings.TrimSuffix(baseURL.Path, "/")) + "//." } baseURL, err = rewriteTemplateURL(ctx, l, opts, baseURL) if err != nil { return "", errors.New(err) } templateDir, err := os.MkdirTemp(tempDir, "template") if err != nil { return "", errors.New(err) } l.Infof("Downloading template from %s into %s", baseURL.String(), templateDir) // Downloading baseURL to support boilerplate dependencies and partials. Go-getter discards all but specified folder if one is provided. if _, err := getter.GetAny(ctx, templateDir, baseURL.String()); err != nil { return "", errors.New(err) } // Add subfolder to templateDir if provided, as scaffold needs path to boilerplate.yml file if subFolder != "" { subFolder = strings.TrimPrefix(subFolder, "/") templateDir = filepath.Join(templateDir, subFolder) // Verify that subfolder exists if _, err := os.Stat(templateDir); os.IsNotExist(err) { return "", errors.Errorf( "subfolder \"//%s\" not found in downloaded template from %s", subFolder, templateURL, ) } } return templateDir, nil } // prepareBoilerplateFiles - prepare boilerplate files from provided template, tf module, or (custom) default template func prepareBoilerplateFiles( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, templateURL, tempDir string, ) (string, error) { boilerplateDir := filepath.Join(tempDir, util.DefaultBoilerplateDir) // process template url if it was passed. This overrides the .boilerplate folder in the OpenTofu/Terraform module if templateURL != "" { // process template url if it was passed tempTemplateDir, err := downloadTemplate(ctx, l, opts, templateURL, tempDir) if err != nil { return "", errors.New(err) } boilerplateDir = tempTemplateDir } // if boilerplate dir is not found, create one with default template if !util.IsDir(boilerplateDir) { _, pctx := configbridge.NewParsingContext(ctx, l, opts) config, err := config.ReadCatalogConfig(ctx, l, pctx) if err != nil { return "", errors.New(err) } // use defaultTemplateURL if defined in config, otherwise use basic default template if config != nil && config.DefaultTemplate != "" { // process template url if available tempTemplateDir, err := downloadTemplate(ctx, l, opts, config.DefaultTemplate, tempDir) if err != nil { return "", errors.New(err) } boilerplateDir = tempTemplateDir } else { defaultTempDir, err := os.MkdirTemp(tempDir, "boilerplate") if err != nil { return "", errors.New(err) } boilerplateDir = defaultTempDir boilerplateDir, err = generateDefaultTemplate(boilerplateDir) if err != nil { return "", errors.New(err) } } } return boilerplateDir, nil } // parseVariables - parse variables from tf files. func parseVariables( l log.Logger, opts *options.TerragruntOptions, moduleDir string, ) ([]*config.ParsedVariable, []*config.ParsedVariable, error) { inputs, err := config.ParseVariables(l, opts.Experiments, opts.StrictControls, moduleDir) if err != nil { return nil, nil, errors.New(err) } // separate variables that require value and with default value var ( requiredVariables []*config.ParsedVariable optionalVariables []*config.ParsedVariable ) for _, value := range inputs { if value.DefaultValue == "" { requiredVariables = append(requiredVariables, value) } else { optionalVariables = append(optionalVariables, value) } } return requiredVariables, optionalVariables, nil } // parseModuleURL - parse module url and rewrite it if required func parseModuleURL( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, vars map[string]any, moduleURL string, ) (string, error) { parsedModuleURL, err := tf.ToSourceURL(moduleURL, opts.WorkingDir) if err != nil { return "", errors.New(err) } moduleURL = parsedModuleURL.String() // rewrite module url, if required parsedModuleURL, err = rewriteModuleURL(l, opts, vars, moduleURL) if err != nil { return "", errors.New(err) } // add ref to module url, if required parsedModuleURL, err = addRefToModuleURL(ctx, l, opts, parsedModuleURL, vars) if err != nil { return "", errors.New(err) } // regenerate module url with all changes return parsedModuleURL.String(), nil } // rewriteModuleURL rewrites module url to git ssh if required // github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs => git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs func rewriteModuleURL( l log.Logger, opts *options.TerragruntOptions, vars map[string]any, moduleURL string, ) (*url.URL, error) { var updatedModuleURL = moduleURL sourceURLType := sourceURLTypeHTTPS if value, found := vars[sourceURLTypeVar]; found { sourceURLType = fmt.Sprintf("%s", value) } // expand module url parsedValue, err := parseURL(l, moduleURL) if err != nil { l.Warnf("Failed to parse module url %s", moduleURL) parsedModuleURL, err := tf.ToSourceURL(updatedModuleURL, opts.WorkingDir) if err != nil { return nil, errors.New(err) } return parsedModuleURL, nil } // try to rewrite module url if is https and is requested to be git // git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs => git::ssh://git@github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs if parsedValue.scheme == "https" && sourceURLType == sourceURLTypeGit { gitUser := sourceGitSSHUser if value, found := vars[sourceGitSSHUserVar]; found { gitUser = fmt.Sprintf("%s", value) } path := strings.TrimPrefix(parsedValue.path, "/") updatedModuleURL = fmt.Sprintf("%s@%s:%s", gitUser, parsedValue.host, path) } // persist changes in url.URL parsedModuleURL, err := tf.ToSourceURL(updatedModuleURL, opts.WorkingDir) if err != nil { return nil, errors.New(err) } return parsedModuleURL, nil } // rewriteTemplateURL rewrites template url with reference to tag // github.com/denis256/terragrunt-tests.git//scaffold/base-template => github.com/denis256/terragrunt-tests.git//scaffold/base-template?ref=v0.53.8 func rewriteTemplateURL( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, parsedTemplateURL *url.URL, ) (*url.URL, error) { var ( updatedTemplateURL = parsedTemplateURL templateParams = updatedTemplateURL.Query() ) ref := templateParams.Get(refParam) if ref == "" { rootSourceURL, _, err := tf.SplitSourceURL(l, updatedTemplateURL) if err != nil { return nil, errors.New(err) } if rootSourceURL.Scheme == "" || rootSourceURL.Scheme == "file" { l.Debugf("Skipping git tag lookup for local template path: %s", rootSourceURL) return updatedTemplateURL, nil } tag, err := shell.GitLastReleaseTag(ctx, l, opts.Env, opts.WorkingDir, rootSourceURL) if err != nil || tag == "" { l.Warnf("Failed to find last release tag for URL %s, so will not add a ref param to the URL", rootSourceURL) } else { templateParams.Add(refParam, tag) updatedTemplateURL.RawQuery = templateParams.Encode() } } return updatedTemplateURL, nil } // addRefToModuleURL adds ref to module url if is passed through variables or find it from git tags func addRefToModuleURL( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, parsedModuleURL *url.URL, vars map[string]any, ) (*url.URL, error) { var moduleURL = parsedModuleURL // append ref to source url, if is passed through variables or find it from git tags params := moduleURL.Query() refReplacement, refVarPassed := vars[refVar] if refVarPassed { params.Set(refParam, fmt.Sprintf("%s", refReplacement)) moduleURL.RawQuery = params.Encode() } ref := params.Get(refParam) if ref == "" { // if ref is not passed, find last release tag // git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs => git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs?ref=v0.53.8 rootSourceURL, _, err := tf.SplitSourceURL(l, moduleURL) if err != nil { return nil, errors.New(err) } tag, err := shell.GitLastReleaseTag(ctx, l, opts.Env, opts.WorkingDir, rootSourceURL) if err != nil || tag == "" { l.Warnf("Failed to find last release tag for %s", rootSourceURL) } else { params.Add(refParam, tag) moduleURL.RawQuery = params.Encode() } } return moduleURL, nil } // parseURL parses module url to scheme, host and path func parseURL(l log.Logger, moduleURL string) (*parsedURL, error) { matches := moduleURLRegex.FindStringSubmatch(moduleURL) if len(matches) != moduleURLParts { l.Warnf("Failed to parse url %s", moduleURL) return nil, failedToParseURLError{} } return &parsedURL{ scheme: matches[1], host: matches[2], path: matches[3], }, nil } type parsedURL struct { scheme string host string path string } type failedToParseURLError struct { } func (err failedToParseURLError) Error() string { return "Failed to parse Url." } type NoModuleURLPassed struct { } func (err NoModuleURLPassed) Error() string { return "No module URL passed." } ================================================ FILE: internal/cli/commands/scaffold/scaffold_test.go ================================================ package scaffold_test import ( "context" "os" "path/filepath" "strings" "testing" boilerplateoptions "github.com/gruntwork-io/boilerplate/options" "github.com/gruntwork-io/boilerplate/templates" "github.com/gruntwork-io/boilerplate/variables" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newTestBoilerplateOptions creates a BoilerplateOptions for testing func newTestBoilerplateOptions(templateFolder, outputFolder string, vars map[string]any, noShell, noHooks bool) *boilerplateoptions.BoilerplateOptions { return &boilerplateoptions.BoilerplateOptions{ TemplateFolder: templateFolder, OutputFolder: outputFolder, OnMissingKey: boilerplateoptions.DefaultMissingKeyAction, OnMissingConfig: boilerplateoptions.DefaultMissingConfigAction, Vars: vars, ShellCommandAnswers: map[string]bool{}, NoShell: noShell, NoHooks: noHooks, NonInteractive: true, DisableDependencyPrompt: false, } } func TestDefaultTemplateVariables(t *testing.T) { t.Parallel() // set pre-defined variables vars := map[string]any{} requiredVariables := make([]*config.ParsedVariable, 0, 1) optionalVariables := make([]*config.ParsedVariable, 0, 1) requiredVariables = append(requiredVariables, &config.ParsedVariable{ Name: "required_var_1", Description: "required_var_1 description", Type: "string", DefaultValuePlaceholder: "\"\"", }) optionalVariables = append(optionalVariables, &config.ParsedVariable{ Name: "optional_var_2", Description: "optional_ver_2 description", Type: "number", DefaultValue: "42", }) vars["requiredVariables"] = requiredVariables vars["optionalVariables"] = optionalVariables vars["sourceUrl"] = "git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs?ref=v0.53.8" vars["EnableRootInclude"] = false vars["RootFileName"] = "root.hcl" workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") err := os.Mkdir(templateDir, 0755) require.NoError(t, err) outputDir := filepath.Join(workDir, "output") err = os.Mkdir(outputDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(templateDir, "terragrunt.hcl"), []byte(scaffold.DefaultTerragruntTemplate), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(scaffold.DefaultBoilerplateConfig), 0644) require.NoError(t, err) boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, vars, true, true) emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) content, err := util.ReadFileAsString(filepath.Join(outputDir, "terragrunt.hcl")) require.NoError(t, err) require.Contains(t, content, "required_var_1") require.Contains(t, content, "optional_var_2") // read generated HCL file and check if it is parsed correctly opts, err := options.NewTerragruntOptionsForTest(filepath.Join(outputDir, "terragrunt.hcl")) require.NoError(t, err) l := logger.CreateLogger() _, pctx := configbridge.NewParsingContext(t.Context(), l, opts) cfg, err := config.ReadTerragruntConfig(t.Context(), l, pctx, config.DefaultParserOptions(l, opts.StrictControls)) require.NoError(t, err) require.NotEmpty(t, cfg.Inputs) assert.Len(t, cfg.Inputs, 1) _, found := cfg.Inputs["required_var_1"] require.True(t, found) require.Equal(t, "git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs?ref=v0.53.8", *cfg.Terraform.Source) } func TestCatalogConfigApplication(t *testing.T) { t.Parallel() testCases := []struct { cliNoShell *bool cliNoHooks *bool name string terragruntConfig string description string expectedNoShell bool expectedNoHooks bool }{ { name: "config_both_flags_true", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = true no_hooks = true }`, expectedNoShell: true, expectedNoHooks: true, description: "Catalog config sets both flags to true", }, { name: "config_both_flags_false", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = false no_hooks = false }`, description: "Catalog config sets both flags to false", }, { name: "config_shell_true_hooks_false", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = true no_hooks = false }`, expectedNoShell: true, description: "Catalog config sets no_shell=true, no_hooks=false", }, { name: "config_shell_false_hooks_true", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = false no_hooks = true }`, expectedNoHooks: true, description: "Catalog config sets no_shell=false, no_hooks=true", }, // Test CLI flags overriding catalog config { name: "cli_override_config_true_with_false", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = true no_hooks = true }`, cliNoShell: boolPtr(false), cliNoHooks: boolPtr(false), description: "CLI flags override catalog config (CLI false > config true)", }, { name: "cli_override_config_false_with_true", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = false no_hooks = false }`, cliNoShell: boolPtr(true), cliNoHooks: boolPtr(true), expectedNoShell: true, expectedNoHooks: true, description: "CLI flags override catalog config (CLI true > config false)", }, { name: "cli_partial_override_shell_only", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = false no_hooks = true }`, cliNoShell: boolPtr(true), expectedNoShell: true, expectedNoHooks: true, description: "CLI --no-shell overrides config, no_hooks from config", }, { name: "cli_partial_override_hooks_only", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = true no_hooks = false }`, cliNoHooks: boolPtr(true), expectedNoShell: true, expectedNoHooks: true, description: "CLI --no-hooks overrides config, no_shell from config", }, // Test behavior when attributes are omitted from config { name: "config_omitted_attributes_no_cli", terragruntConfig: ` catalog { urls = ["test-url"] }`, description: "Config omits no_shell/no_hooks, no CLI flags - should default to false", }, { name: "config_omitted_attributes_cli_true", terragruntConfig: ` catalog { urls = ["test-url"] }`, cliNoShell: boolPtr(true), cliNoHooks: boolPtr(true), expectedNoShell: true, expectedNoHooks: true, description: "Config omits no_shell/no_hooks, CLI sets both true - CLI should take effect", }, { name: "config_omitted_attributes_cli_false", terragruntConfig: ` catalog { urls = ["test-url"] }`, cliNoShell: boolPtr(false), cliNoHooks: boolPtr(false), description: "Config omits no_shell/no_hooks, CLI sets both false - should remain false", }, { name: "config_omitted_attributes_cli_partial", terragruntConfig: ` catalog { urls = ["test-url"] }`, cliNoShell: boolPtr(true), expectedNoShell: true, description: "Config omits attributes, only CLI --no-shell set - only no_shell should be true", }, // Test mixed scenarios with some attributes omitted { name: "config_partial_shell_only_no_cli", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = true }`, expectedNoShell: true, description: "Config sets only no_shell=true, no_hooks omitted - should be true/false", }, { name: "config_partial_hooks_only_no_cli", terragruntConfig: ` catalog { urls = ["test-url"] no_hooks = true }`, expectedNoHooks: true, description: "Config sets only no_hooks=true, no_shell omitted - should be false/true", }, { name: "config_partial_shell_only_cli_override_hooks", terragruntConfig: ` catalog { urls = ["test-url"] no_shell = false }`, cliNoHooks: boolPtr(true), expectedNoHooks: true, description: "Config sets no_shell=false, no_hooks omitted, CLI --no-hooks - should be false/true", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) configDir := filepath.Join(workDir, "config") err := os.MkdirAll(configDir, 0755) require.NoError(t, err) terragruntConfigPath := filepath.Join(configDir, "terragrunt.hcl") err = os.WriteFile(terragruntConfigPath, []byte(tc.terragruntConfig), 0644) require.NoError(t, err) opts := options.NewTerragruntOptions() // Set CLI flags if specified in test case if tc.cliNoShell != nil { opts.NoShell = *tc.cliNoShell } else { opts.NoShell = false } if tc.cliNoHooks != nil { opts.NoHooks = *tc.cliNoHooks } else { opts.NoHooks = false } opts.TerragruntConfigPath = terragruntConfigPath opts.WorkingDir = configDir opts.ScaffoldRootFileName = "terragrunt.hcl" l := logger.CreateLogger() // First, verify catalog config parsing _, catalogPctx := configbridge.NewParsingContext(context.Background(), l, opts) catalogCfg, err := config.ReadCatalogConfig(context.Background(), l, catalogPctx) require.NoError(t, err) require.NotNil(t, catalogCfg, tc.description) // Verify config parsing based on whether attributes are present in the config if strings.Contains(tc.terragruntConfig, "no_shell") { assert.NotNil(t, catalogCfg.NoShell, "NoShell should not be nil when specified in config: %s", tc.description) } else { assert.Nil(t, catalogCfg.NoShell, "NoShell should be nil when omitted from config: %s", tc.description) } if strings.Contains(tc.terragruntConfig, "no_hooks") { assert.NotNil(t, catalogCfg.NoHooks, "NoHooks should not be nil when specified in config: %s", tc.description) } else { assert.Nil(t, catalogCfg.NoHooks, "NoHooks should be nil when omitted from config: %s", tc.description) } // Apply catalog config settings to options (simulating scaffold.Run behavior) // Only apply config values if CLI flags weren't explicitly set if tc.cliNoShell == nil && catalogCfg.NoShell != nil && *catalogCfg.NoShell { opts.NoShell = true } if tc.cliNoHooks == nil && catalogCfg.NoHooks != nil && *catalogCfg.NoHooks { opts.NoHooks = true } // Verify final option values match expected (after config application + CLI override) assert.Equal(t, tc.expectedNoShell, opts.NoShell, "Final NoShell value should match expected: %s", tc.description) assert.Equal(t, tc.expectedNoHooks, opts.NoHooks, "Final NoHooks value should match expected: %s", tc.description) }) } } // Helper function to create bool pointers func boolPtr(b bool) *bool { return &b } // TestCatalogConfigParsing tests that catalog config is properly parsed with new attributes func TestCatalogConfigParsing(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) // Test with no_shell and no_hooks attributes terragruntConfig := ` catalog { default_template = "test-template" urls = ["url1", "url2"] no_shell = true no_hooks = false } ` terragruntConfigPath := filepath.Join(workDir, "terragrunt.hcl") err := os.WriteFile(terragruntConfigPath, []byte(terragruntConfig), 0644) require.NoError(t, err) opts := options.NewTerragruntOptions() opts.TerragruntConfigPath = terragruntConfigPath opts.WorkingDir = workDir opts.ScaffoldRootFileName = "terragrunt.hcl" l := logger.CreateLogger() // Parse the configuration _, catalogPctx := configbridge.NewParsingContext(context.Background(), l, opts) catalogCfg, err := config.ReadCatalogConfig(context.Background(), l, catalogPctx) require.NoError(t, err) require.NotNil(t, catalogCfg) // Verify all fields are correctly parsed assert.Equal(t, "test-template", catalogCfg.DefaultTemplate) assert.Equal(t, []string{"url1", "url2"}, catalogCfg.URLs) assert.NotNil(t, catalogCfg.NoShell) assert.True(t, *catalogCfg.NoShell) assert.NotNil(t, catalogCfg.NoHooks) assert.False(t, *catalogCfg.NoHooks) } // TestCatalogConfigOptional tests that no_shell and no_hooks are optional attributes func TestCatalogConfigOptional(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) // Test without no_shell and no_hooks attributes terragruntConfig := ` catalog { default_template = "test-template" urls = ["url1"] } ` terragruntConfigPath := filepath.Join(workDir, "terragrunt.hcl") err := os.WriteFile(terragruntConfigPath, []byte(terragruntConfig), 0644) require.NoError(t, err) opts := options.NewTerragruntOptions() opts.TerragruntConfigPath = terragruntConfigPath opts.WorkingDir = workDir opts.ScaffoldRootFileName = "terragrunt.hcl" l := logger.CreateLogger() // Parse the configuration _, catalogPctx := configbridge.NewParsingContext(context.Background(), l, opts) catalogCfg, err := config.ReadCatalogConfig(context.Background(), l, catalogPctx) require.NoError(t, err) require.NotNil(t, catalogCfg) // Verify optional fields are nil when not specified assert.Equal(t, "test-template", catalogCfg.DefaultTemplate) assert.Equal(t, []string{"url1"}, catalogCfg.URLs) assert.Nil(t, catalogCfg.NoShell, "NoShell should be nil when not specified") assert.Nil(t, catalogCfg.NoHooks, "NoHooks should be nil when not specified") } // TestBoilerplateShellTemplateFunctionDisabled tests that NoShell=true disables shell template functions func TestBoilerplateShellTemplateFunctionDisabled(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") outputDir := filepath.Join(workDir, "output") // Create template and output directories err := os.MkdirAll(templateDir, 0755) require.NoError(t, err) err = os.MkdirAll(outputDir, 0755) require.NoError(t, err) // Create boilerplate.yml boilerplateConfig := ` variables: - name: TestVar description: A test variable type: string default: "test-value" ` err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(boilerplateConfig), 0644) require.NoError(t, err) // Create template file with shell template function templateContent := `# Test template with shell function test_var = "{{ .TestVar }}" # This shell function should NOT execute when NoShell=true shell_output = "{{ shell "echo SHELL_EXECUTED" }}" ` err = os.WriteFile(filepath.Join(templateDir, "test.txt"), []byte(templateContent), 0644) require.NoError(t, err) // Create BoilerplateOptions with NoShell=true boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, map[string]any{}, true, false) // Process the template emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) // Verify the file was generated generatedFile := filepath.Join(outputDir, "test.txt") require.FileExists(t, generatedFile) content, err := util.ReadFileAsString(generatedFile) require.NoError(t, err) // Verify that template variables were processed assert.Contains(t, content, "test-value", "Template variable should be processed") // When shell is disabled, the shell function should remain unprocessed // Note: The exact behavior depends on how boilerplate handles disabled shell functions // It might either leave the template as-is or throw an error assert.NotContains(t, content, "SHELL_EXECUTED", "Shell function should not execute when NoShell=true") } // TestBoilerplateShellTemplateFunctionEnabled tests that NoShell=false allows shell template functions func TestBoilerplateShellTemplateFunctionEnabled(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") outputDir := filepath.Join(workDir, "output") // Create template and output directories err := os.MkdirAll(templateDir, 0755) require.NoError(t, err) err = os.MkdirAll(outputDir, 0755) require.NoError(t, err) // Create boilerplate.yml boilerplateConfig := ` variables: - name: TestVar description: A test variable type: string default: "test-value" ` err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(boilerplateConfig), 0644) require.NoError(t, err) // Create template file with shell template function templateContent := `# Test template with shell function test_var = "{{ .TestVar }}" # This shell function SHOULD execute when NoShell=false shell_output = "{{ shell "echo" "SHELL_EXECUTED" }}" ` err = os.WriteFile(filepath.Join(templateDir, "test.txt"), []byte(templateContent), 0644) require.NoError(t, err) // Create BoilerplateOptions with NoShell=false boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, map[string]any{}, false, false) // Process the template emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) // Verify the file was generated generatedFile := filepath.Join(outputDir, "test.txt") require.FileExists(t, generatedFile) content, err := util.ReadFileAsString(generatedFile) require.NoError(t, err) // Verify that template variables were processed assert.Contains(t, content, "test-value", "Template variable should be processed") // When shell is enabled, the shell function should execute and output should be present assert.Contains(t, content, "SHELL_EXECUTED", "Shell function should execute when NoShell=false") } // TestBoilerplateHooksDisabled tests that NoHooks=true disables hooks func TestBoilerplateHooksDisabled(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") outputDir := filepath.Join(workDir, "output") // Create template and output directories err := os.MkdirAll(templateDir, 0755) require.NoError(t, err) err = os.MkdirAll(outputDir, 0755) require.NoError(t, err) // Create boilerplate.yml with hooks boilerplateConfig := ` variables: - name: TestVar description: A test variable type: string default: "test-value" hooks: before: - command: touch args: - ` + outputDir + `/before_hook_not_executed.txt description: "Test hook that should NOT execute" after: - command: touch args: - ` + outputDir + `/after_hook_not_executed.txt description: "Test hook that should NOT execute" ` err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(boilerplateConfig), 0644) require.NoError(t, err) // Create simple template file templateContent := `# Test template test_var = "{{ .TestVar }}" ` err = os.WriteFile(filepath.Join(templateDir, "test.txt"), []byte(templateContent), 0644) require.NoError(t, err) // Create BoilerplateOptions with NoHooks=true boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, map[string]any{}, false, true) // Process the template emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) // Verify the template file was generated generatedFile := filepath.Join(outputDir, "test.txt") require.FileExists(t, generatedFile) content, err := util.ReadFileAsString(generatedFile) require.NoError(t, err) assert.Contains(t, content, "test-value", "Template variable should be processed") // Verify that hooks did NOT execute (hook files should not exist) beforeHookFile := filepath.Join(outputDir, "before_hook_not_executed.txt") afterHookFile := filepath.Join(outputDir, "after_hook_not_executed.txt") assert.NoFileExists(t, beforeHookFile, "Before hook file should not exist when NoHooks=true") assert.NoFileExists(t, afterHookFile, "After hook file should not exist when NoHooks=true") } // TestBoilerplateHooksEnabled tests that NoHooks=false allows hooks to execute func TestBoilerplateHooksEnabled(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") outputDir := filepath.Join(workDir, "output") // Create template and output directories err := os.MkdirAll(templateDir, 0755) require.NoError(t, err) err = os.MkdirAll(outputDir, 0755) require.NoError(t, err) // Create boilerplate.yml with hooks boilerplateConfig := ` variables: - name: TestVar description: A test variable type: string default: "test-value" hooks: before: - command: touch args: - ` + outputDir + `/before_hook_executed.txt description: "Test hook that SHOULD execute" after: - command: touch args: - ` + outputDir + `/after_hook_executed.txt description: "Test hook that SHOULD execute" ` err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(boilerplateConfig), 0644) require.NoError(t, err) // Create simple template file templateContent := `# Test template test_var = "{{ .TestVar }}" ` err = os.WriteFile(filepath.Join(templateDir, "test.txt"), []byte(templateContent), 0644) require.NoError(t, err) // Create BoilerplateOptions with NoHooks=false boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, map[string]any{}, false, false) // Process the template emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) // Verify the template file was generated generatedFile := filepath.Join(outputDir, "test.txt") require.FileExists(t, generatedFile) content, err := util.ReadFileAsString(generatedFile) require.NoError(t, err) assert.Contains(t, content, "test-value", "Template variable should be processed") // Verify that hooks DID execute (before and after hook files should exist) beforeHookFile := filepath.Join(outputDir, "before_hook_executed.txt") afterHookFile := filepath.Join(outputDir, "after_hook_executed.txt") require.FileExists(t, beforeHookFile, "Before hook file should exist when NoHooks=false") require.FileExists(t, afterHookFile, "After hook file should exist when NoHooks=false") } // TestBoilerplateBothFlagsDisabled tests that both NoShell=true and NoHooks=true work together func TestBoilerplateBothFlagsDisabled(t *testing.T) { t.Parallel() workDir := helpers.TmpDirWOSymlinks(t) templateDir := filepath.Join(workDir, "template") outputDir := filepath.Join(workDir, "output") // Create template and output directories err := os.MkdirAll(templateDir, 0755) require.NoError(t, err) err = os.MkdirAll(outputDir, 0755) require.NoError(t, err) // Create boilerplate.yml with both hooks and variables boilerplateConfig := ` variables: - name: TestVar description: A test variable type: string default: "test-value" hooks: before: - command: echo "HOOK_EXECUTED" > ` + outputDir + `/hook_output.txt description: "Test hook that should NOT execute" ` err = os.WriteFile(filepath.Join(templateDir, "boilerplate.yml"), []byte(boilerplateConfig), 0644) require.NoError(t, err) // Create template file with shell template function templateContent := `# Test template test_var = "{{ .TestVar }}" shell_result = "{{ shell "echo SHELL_EXECUTED" }}" ` err = os.WriteFile(filepath.Join(templateDir, "test.txt"), []byte(templateContent), 0644) require.NoError(t, err) // Create BoilerplateOptions with both NoShell=true and NoHooks=true boilerplateOpts := newTestBoilerplateOptions(templateDir, outputDir, map[string]any{}, true, true) // Process the template emptyDep := variables.Dependency{} err = templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep) require.NoError(t, err) // Verify the template file was generated generatedFile := filepath.Join(outputDir, "test.txt") require.FileExists(t, generatedFile) content, err := util.ReadFileAsString(generatedFile) require.NoError(t, err) // Verify that template variables were processed assert.Contains(t, content, "test-value", "Template variable should be processed") // Verify that shell function did NOT execute assert.NotContains(t, content, "SHELL_EXECUTED", "Shell function should not execute when NoShell=true") // Verify that hooks did NOT execute hookOutputFile := filepath.Join(outputDir, "hook_output.txt") assert.NoFileExists(t, hookOutputFile, "Hook should not execute when NoHooks=true") } ================================================ FILE: internal/cli/commands/shortcuts.go ================================================ package commands import ( "slices" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" ) var ( shortcutCommandNames = []string{ tf.CommandNameInit, tf.CommandNameValidate, tf.CommandNamePlan, tf.CommandNameApply, tf.CommandNameDestroy, tf.CommandNameForceUnlock, tf.CommandNameImport, tf.CommandNameOutput, tf.CommandNameRefresh, tf.CommandNameShow, tf.CommandNameState, tf.CommandNameTest, } ) func NewShortcutsCommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { var ( runCmd = run.NewCommand(l, opts) cmds = make(clihelper.Commands, 0, len(runCmd.Subcommands)) ) for _, runSubCmd := range runCmd.Subcommands { if isNotShortcutCmd := !slices.Contains(shortcutCommandNames, runSubCmd.Name); isNotShortcutCmd { continue } cmd := &clihelper.Command{ Name: runSubCmd.Name, Usage: runSubCmd.Usage, Flags: runCmd.Flags, CustomHelp: runSubCmd.CustomHelp, Action: runSubCmd.Action, DisabledErrorOnUndefinedFlag: true, } cmds = append(cmds, cmd) } return cmds } ================================================ FILE: internal/cli/commands/stack/cli.go ================================================ // Package stack provides the command to stack. package stack import ( "context" runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( // CommandName stack command name. CommandName = "stack" OutputFormatFlagName = "format" JSONFormatFlagName = "json" RawFormatFlagName = "raw" NoStackValidate = "no-stack-validate" generateCommandName = "generate" runCommandName = "run" outputCommandName = "output" cleanCommandName = "clean" rawOutputFormat = "raw" jsonOutputFormat = "json" ) // NewCommand builds the command for stack. func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Terragrunt stack commands.", Subcommands: clihelper.Commands{ &clihelper.Command{ Name: generateCommandName, Usage: "Generate a stack from a terragrunt.stack.hcl file", Action: func(ctx context.Context, _ *clihelper.Context) error { return RunGenerate(ctx, l, opts.OptionsFromContext(ctx)) }, Flags: defaultFlags(l, opts, nil), }, &clihelper.Command{ Name: runCommandName, Usage: "Run a command on the stack generated from the current directory", Action: func(ctx context.Context, _ *clihelper.Context) error { return Run(ctx, l, opts.OptionsFromContext(ctx)) }, Flags: defaultFlags(l, opts, nil), }, &clihelper.Command{ Name: outputCommandName, Usage: "Run fetch stack output", Action: func(ctx context.Context, cliCtx *clihelper.Context) error { index := "" if val := cliCtx.Args().Get(0); val != "" { index = val } return RunOutput(ctx, l, opts.OptionsFromContext(ctx), index) }, Flags: outputFlags(l, opts, nil), }, &clihelper.Command{ Name: cleanCommandName, Usage: "Clean the stack generated from the current directory", Action: func(ctx context.Context, _ *clihelper.Context) error { return RunClean(ctx, l, opts.OptionsFromContext(ctx)) }, }, }, Action: clihelper.ShowCommandHelp, } } func defaultFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) flags := clihelper.Flags{ flags.NewFlag(&clihelper.BoolFlag{ Name: NoStackValidate, EnvVars: tgPrefix.EnvVars(NoStackValidate), Destination: &opts.NoStackValidate, Hidden: true, Usage: "Disable automatic stack validation after generation.", }), } return append(runcmd.NewFlags(l, opts, nil), flags...) } func outputFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) flags := clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: OutputFormatFlagName, EnvVars: tgPrefix.EnvVars(OutputFormatFlagName), Destination: &opts.StackOutputFormat, Usage: "Stack output format. Valid values are: json, raw", }), flags.NewFlag(&clihelper.BoolFlag{ Name: RawFormatFlagName, Usage: "Stack output in raw format", Action: func(_ context.Context, _ *clihelper.Context, value bool) error { opts.StackOutputFormat = rawOutputFormat return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: JSONFormatFlagName, Usage: "Stack output in json format", Action: func(_ context.Context, _ *clihelper.Context, value bool) error { opts.StackOutputFormat = jsonOutputFormat return nil }, }), } return append(defaultFlags(l, opts, prefix), flags...) } ================================================ FILE: internal/cli/commands/stack/output.go ================================================ package stack import ( "bytes" "encoding/json" "io" "github.com/hashicorp/hcl/v2/hclwrite" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) // PrintRawOutputs formats outputs for raw output format, similar to Tofu's output -raw. // When the output is a raw output for a specific path, it will extract the raw value without quotes // or formatting and write it directly to the provided writer. // It only supports primitive values (strings, numbers, and booleans) and will return an error for complex types. func PrintRawOutputs(_ *options.TerragruntOptions, writer io.Writer, outputs cty.Value) error { if outputs == cty.NilVal { return nil } // Extract the value from the nested structure, if any valueMap := outputs.AsValueMap() length := len(valueMap) if length == 0 { return nil } if length == 1 { // Single output, try to extract the final value finalValue, err := extractSingleValue(valueMap) if err != nil { return err } return writePrimitiveValue(writer, finalValue, config.GetFirstKey(valueMap)) } // Multiple top-level keys, can't provide a single raw output return errors.New("The -raw option requires a single output value. There are multiple outputs " + "available in the current stack. Please specify which output you want to display by using " + "the full output key as an argument to the command.") } // extractSingleValue extracts a single primitive value from a map with only one element, // potentially traversing through a nested object structure. func extractSingleValue(valueMap map[string]cty.Value) (cty.Value, error) { topKey := config.GetFirstKey(valueMap) topValue := valueMap[topKey] // If the value is not an object type, return it directly if !topValue.Type().IsObjectType() { return topValue, nil } // Try to navigate to the leaf value through nested objects return traverseNestedObject(topKey, topValue) } // traverseNestedObject follows a chain of nested objects to find a primitive value at the leaf. // Returns an error if a complex value is found at the leaf or if multiple paths are present. func traverseNestedObject(topKey string, topValue cty.Value) (cty.Value, error) { currentValue := topValue currentKey := topKey var finalValue cty.Value // Traverse down the nested objects for currentValue.Type().IsObjectType() { nestedMap := currentValue.AsValueMap() if len(nestedMap) != 1 { // If we have more than one key at any level, we can't get a single raw value return cty.NilVal, createUnsupportedValueError(currentKey, currentValue) } // Get the only key-value pair in the nested object nextKey := config.GetFirstKey(nestedMap) nextValue := nestedMap[nextKey] currentKey = nextKey currentValue = nextValue // If we've reached a primitive value, we're done if !currentValue.Type().IsObjectType() && !currentValue.Type().IsMapType() { finalValue = currentValue break } } // If we didn't set finalValue, the nested structure didn't lead to a primitive if finalValue == cty.NilVal { return cty.NilVal, createUnsupportedValueError(topKey, topValue) } return finalValue, nil } // writePrimitiveValue writes a primitive value to the writer. // Returns an error if the value is null or a complex type. func writePrimitiveValue(writer io.Writer, value cty.Value, path string) error { // Check if the value is null if value.IsNull() { return errors.New("Error: Unsupported value for raw output\n\n" + "The -raw option only supports strings, numbers, and boolean values, but the output value is null.\n\n" + "Use the -json option for machine-readable representations of output values that have complex types.") } // Check if the value is a complex type if config.IsComplexType(value) { return createUnsupportedValueError(path, value) } // Unmark the value if it's marked (like with "sensitive") if value.IsMarked() { value, _ = value.Unmark() } valueStr, err := config.FormatValue(value) if err != nil { return errors.New(err) } // Write the raw value without any formatting if _, err := writer.Write([]byte(valueStr)); err != nil { return errors.New(err) } return nil } // createUnsupportedValueError creates a formatted error for unsupported value types. func createUnsupportedValueError(path string, value cty.Value) error { return errors.New("Error: Unsupported value for raw output\n\n" + "The -raw option only supports strings, numbers, and boolean values, but output value \"" + path + "\" is " + value.Type().FriendlyName() + ".\n\n" + "Use the -json option for machine-readable representations of output values that have complex types.") } // PrintOutputs formats outputs as HCL and writes them to the provided writer. // It creates a new HCL file with each top-level output as an attribute, preserving the // original structure of complex types like maps and objects. func PrintOutputs(writer io.Writer, outputs cty.Value) error { if outputs == cty.NilVal { return nil } f := hclwrite.NewEmptyFile() rootBody := f.Body() for key, val := range outputs.AsValueMap() { rootBody.SetAttributeRaw(key, hclwrite.TokensForValue(val)) } if _, err := writer.Write(f.Bytes()); err != nil { return errors.New(err) } return nil } // PrintJSONOutput formats outputs as pretty-printed JSON with 2-space indentation. // It marshals the cty.Value data to JSON using the go-cty library and writes the formatted // result to the provided writer. func PrintJSONOutput(writer io.Writer, outputs cty.Value) error { if outputs == cty.NilVal { return nil } rawJSON, err := ctyjson.Marshal(outputs, outputs.Type()) if err != nil { return errors.New(err) } var pretty bytes.Buffer if err := json.Indent(&pretty, rawJSON, "", " "); err != nil { return errors.New(err) } if _, err := writer.Write(pretty.Bytes()); err != nil { return errors.New(err) } return nil } ================================================ FILE: internal/cli/commands/stack/output_test.go ================================================ package stack_test import ( "bytes" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/cli/commands/stack" ) func TestPrintRawOutputsBasicTypes(t *testing.T) { t.Parallel() tests := []struct { name string value cty.Value expected string message string }{ { name: "String Value", value: cty.StringVal("value1"), expected: "value1", message: "String values should be printed without quotes", }, { name: "Number Value", value: cty.NumberIntVal(42), expected: "42", message: "Number values should be printed as is", }, { name: "Boolean Value", value: cty.BoolVal(true), expected: "true", message: "Boolean values should be printed as is", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": tt.value, }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.NoError(t, err) assert.Equal(t, tt.expected, buffer.String(), tt.message) }) } } func TestPrintRawOutputsComplexObject(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": cty.MapVal(map[string]cty.Value{ "nested": cty.StringVal("value"), }), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.Error(t, err, "Complex objects should return an error") assert.Contains(t, err.Error(), "Unsupported value for raw output") assert.Contains(t, err.Error(), "key1") } func TestPrintRawOutputsMultipleKeys(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": cty.StringVal("value1"), "key2": cty.NumberIntVal(2), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.Error(t, err, "Multiple keys should return an error") assert.Contains(t, err.Error(), "requires a single output value") } func TestPrintRawOutputsList(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.Error(t, err, "List values should return an error") assert.Contains(t, err.Error(), "Unsupported value for raw output") assert.Contains(t, err.Error(), "key1") assert.Contains(t, err.Error(), "list") } func TestPrintRawOutputsNil(t *testing.T) { t.Parallel() var buffer bytes.Buffer err := stack.PrintRawOutputs(nil, &buffer, cty.NilVal) require.NoError(t, err) assert.Empty(t, buffer.String()) } func TestPrintOutputs(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": cty.StringVal("value1"), "key2": cty.NumberIntVal(2), }) err := stack.PrintOutputs(&buffer, outputs) require.NoError(t, err) assert.Contains(t, buffer.String(), "key1 = \"value1\"") assert.Contains(t, buffer.String(), "key2 = 2") } func TestPrintJSONOutput(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "key1": cty.StringVal("value1"), "key2": cty.NumberIntVal(2), }) err := stack.PrintJSONOutput(&buffer, outputs) require.NoError(t, err) assert.JSONEq(t, `{"key1":"value1","key2":2}`, buffer.String()) } func TestPrintRawOutputsEdgeCases(t *testing.T) { t.Parallel() tests := []struct { outputs cty.Value name string expected string expectError bool }{ { name: "Empty Outputs", outputs: cty.ObjectVal(map[string]cty.Value{}), expectError: false, expected: "", }, { name: "Nil Outputs", outputs: cty.NilVal, expectError: false, expected: "", }, { name: "Single Nested Structure with Single Value", outputs: cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child": cty.StringVal("value"), }), }), expectError: false, expected: "value", }, { name: "Multi-level Nested Structure", outputs: cty.ObjectVal(map[string]cty.Value{ "level1": cty.ObjectVal(map[string]cty.Value{ "level2": cty.ObjectVal(map[string]cty.Value{ "level3": cty.StringVal("deep_value"), }), }), }), expectError: false, expected: "deep_value", }, { name: "Multiple Top-level Keys", outputs: cty.ObjectVal(map[string]cty.Value{ "string": cty.StringVal("text"), "number": cty.NumberIntVal(42), }), expectError: true, expected: "", }, { name: "List Output (Complex Type)", outputs: cty.ObjectVal(map[string]cty.Value{ "list": cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), }), expectError: true, expected: "", }, { name: "Map Output (Complex Type)", outputs: cty.ObjectVal(map[string]cty.Value{ "map": cty.MapVal(map[string]cty.Value{ "a": cty.StringVal("value"), }), }), expectError: true, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer err := stack.PrintRawOutputs(nil, &buffer, tt.outputs) if tt.expectError { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tt.expected, buffer.String()) } }) } } // Additional test case for deeper nested structures func TestPrintRawOutputsDeepNesting(t *testing.T) { t.Parallel() var buffer bytes.Buffer // Create a more deeply nested structure outputs := cty.ObjectVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ "b": cty.ObjectVal(map[string]cty.Value{ "c": cty.ObjectVal(map[string]cty.Value{ "d": cty.ObjectVal(map[string]cty.Value{ "e": cty.StringVal("very_nested_value"), }), }), }), }), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.NoError(t, err) assert.Equal(t, "very_nested_value", buffer.String(), "Should extract deeply nested values") } // Test partial nested pattern func TestPrintRawOutputsPartialNesting(t *testing.T) { t.Parallel() var buffer bytes.Buffer // Create a structure where a nested value terminates with a complex value outputs := cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child": cty.ObjectVal(map[string]cty.Value{ "complex": cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), }), }), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.Error(t, err) assert.Contains(t, err.Error(), "Unsupported value for raw output") } // Test the boundary case where there's exactly one leaf node value func TestPrintRawOutputsExactlyOneLeafNode(t *testing.T) { t.Parallel() var buffer bytes.Buffer // Create a structure with one leaf node that is a string outputs := cty.ObjectVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ "b": cty.ObjectVal(map[string]cty.Value{ "c": cty.StringVal("leaf_value"), }), }), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.NoError(t, err) assert.Equal(t, "leaf_value", buffer.String(), "Should extract the single leaf value") } // Test with special characters in the string func TestPrintRawOutputsSpecialCharacters(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "special": cty.StringVal("value with spaces, quotes \" and special chars @#$%^&*()"), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.NoError(t, err) assert.Equal(t, "value with spaces, quotes \" and special chars @#$%^&*()", buffer.String(), "Should preserve special characters in the output") } // Test with null value func TestPrintRawOutputsNullValue(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "null_val": cty.NullVal(cty.String), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.Error(t, err) assert.Contains(t, err.Error(), "Unsupported value for raw output") } func TestPrintOutputsEdgeCases(t *testing.T) { t.Parallel() tests := []struct { name string outputs cty.Value expected []string }{ { name: "Empty Outputs", outputs: cty.ObjectVal(map[string]cty.Value{}), expected: []string{}, }, { name: "Nil Outputs", outputs: cty.NilVal, expected: []string{}, }, { name: "Nested Structures", outputs: cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child": cty.StringVal("value"), }), }), expected: []string{"parent = {", "child = \"value\""}, }, { name: "Different Data Types", outputs: cty.ObjectVal(map[string]cty.Value{ "string": cty.StringVal("text"), "number": cty.NumberIntVal(42), "bool": cty.BoolVal(true), "list": cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), }), expected: []string{ "string = \"text\"", "number = 42", "bool = true", "list = [", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer err := stack.PrintOutputs(&buffer, tt.outputs) require.NoError(t, err) output := buffer.String() for _, expectedLine := range tt.expected { assert.Contains(t, output, expectedLine) } }) } } func TestPrintJSONOutputEdgeCases(t *testing.T) { t.Parallel() tests := []struct { name string outputs cty.Value expected string isNil bool }{ { name: "Empty Outputs", outputs: cty.ObjectVal(map[string]cty.Value{}), expected: "{}", isNil: false, }, { name: "Nil Outputs", outputs: cty.NilVal, expected: "", isNil: true, }, { name: "Nested Structures", outputs: cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child": cty.StringVal("value"), }), }), expected: `{"parent":{"child":"value"}}`, isNil: false, }, { name: "Different Data Types", outputs: cty.ObjectVal(map[string]cty.Value{ "string": cty.StringVal("text"), "number": cty.NumberIntVal(42), "bool": cty.BoolVal(true), "list": cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), }), expected: `{"string":"text","number":42,"bool":true,"list":["a","b"]}`, isNil: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer err := stack.PrintJSONOutput(&buffer, tt.outputs) require.NoError(t, err) if tt.isNil { assert.Equal(t, tt.expected, buffer.String()) } else { assert.JSONEq(t, tt.expected, buffer.String()) } }) } } func TestPrintRawOutputsNestedValues(t *testing.T) { t.Parallel() tests := []struct { name string value cty.Value expected string }{ { name: "String Value", value: cty.StringVal("nested_text"), expected: "nested_text", }, { name: "Number Value", value: cty.NumberIntVal(42), expected: "42", }, { name: "Boolean Value", value: cty.BoolVal(true), expected: "true", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer outputs := cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child": cty.ObjectVal(map[string]cty.Value{ "value": tt.value, }), }), }) err := stack.PrintRawOutputs(nil, &buffer, outputs) require.NoError(t, err) assert.Equal(t, tt.expected, buffer.String(), "Should extract the nested %s value", tt.name) }) } } func TestPrintRawOutputsSpecialCases(t *testing.T) { t.Parallel() tests := []struct { outputs cty.Value name string errorMsg string expected string expectError bool }{ { name: "Nested Multiple Keys", outputs: cty.ObjectVal(map[string]cty.Value{ "parent": cty.ObjectVal(map[string]cty.Value{ "child1": cty.StringVal("value1"), "child2": cty.StringVal("value2"), }), }), expectError: true, errorMsg: "Unsupported value for raw output", expected: "", }, { name: "Marked String", outputs: cty.ObjectVal(map[string]cty.Value{ "marked_string": cty.StringVal("marked_value").Mark("sensitive"), }), expectError: false, errorMsg: "", expected: "marked_value", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var buffer bytes.Buffer err := stack.PrintRawOutputs(nil, &buffer, tt.outputs) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), tt.errorMsg) } else { require.NoError(t, err) assert.Equal(t, tt.expected, buffer.String(), "Should handle %s correctly", tt.name) } }) } } ================================================ FILE: internal/cli/commands/stack/stack.go ================================================ package stack import ( "context" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/stacks/clean" "github.com/gruntwork-io/terragrunt/internal/stacks/generate" "github.com/gruntwork-io/terragrunt/internal/stacks/output" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/options" ) // RunGenerate runs the stack command. func RunGenerate(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile) if opts.NoStackGenerate { l.Debugf("Skipping stack generation for %s", opts.TerragruntStackConfigPath) return nil } opts.StackAction = "generate" // Clean stack folders before calling `generate` when the `--source-update` flag is passed if opts.SourceUpdate { err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_clean", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { l.Debugf("Running stack clean for %s, as part of generate command", opts.WorkingDir) return clean.CleanStacks(l, opts) }) if err != nil { return errors.Errorf("failed to clean stack directories under %q: %w", opts.WorkingDir, err) } } filters := opts.Filters gitFilters := filters.UniqueGitFilters() // Only create worktrees when git filter expressions are present var wts *worktrees.Worktrees if len(gitFilters) > 0 { var err error wts, err = worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if err != nil { return errors.Errorf("failed to create worktrees: %w", err) } defer func() { cleanupErr := wts.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() } return telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { return generate.GenerateStacks(ctx, l, opts, wts) }) } // Run execute stack command. func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { opts.StackAction = "run" err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_run", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { return RunGenerate(ctx, l, opts) }) if err != nil { return err } return runall.Run(ctx, l, opts) } // RunOutput stack output. func RunOutput(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, index string) error { opts.StackAction = "output" var outputs cty.Value // collect outputs err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_output", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { stackOutputs, err := output.StackOutput(ctx, l, opts) outputs = stackOutputs return err }) if err != nil { return errors.New(err) } // Filter outputs based on index key filteredOutputs := FilterOutputs(outputs, index) // render outputs switch opts.StackOutputFormat { default: if err := PrintOutputs(opts.Writers.Writer, filteredOutputs); err != nil { return errors.New(err) } case rawOutputFormat: if err := PrintRawOutputs(opts, opts.Writers.Writer, filteredOutputs); err != nil { return errors.New(err) } case jsonOutputFormat: if err := PrintJSONOutput(opts.Writers.Writer, filteredOutputs); err != nil { return errors.New(err) } } return nil } // FilterOutputs filters the outputs based on the provided index key. func FilterOutputs(outputs cty.Value, index string) cty.Value { if !outputs.IsKnown() || outputs.IsNull() || len(index) == 0 { return outputs } // Split the index into parts indexParts := strings.Split(index, ".") // Traverse the map using the index parts currentValue := outputs for _, part := range indexParts { // Check if the current value is a map or object if currentValue.Type().IsObjectType() || currentValue.Type().IsMapType() { valueMap := currentValue.AsValueMap() if nextValue, exists := valueMap[part]; exists { currentValue = nextValue } else { // If any part of the index path is not found, return NilVal return cty.NilVal } } else { // If the current value is not a map or object, return NilVal return cty.NilVal } } // Reconstruct the nested map structure nested := currentValue for i := len(indexParts) - 1; i >= 0; i-- { nested = cty.ObjectVal(map[string]cty.Value{ indexParts[i]: nested, }) } return nested } // RunClean recursively removes all stack directories under the specified WorkingDir. func RunClean(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { telemeter := telemetry.TelemeterFromContext(ctx) err := telemeter.Collect(ctx, "stack_clean", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { return clean.CleanStacks(l, opts) }) if err != nil { return errors.Errorf("failed to clean stack directories under %q: %w", opts.WorkingDir, err) } return nil } ================================================ FILE: internal/cli/commands/version/cli.go ================================================ // Package version represents the version CLI command that works the same as the `--version` flag. package version import ( "context" "github.com/gruntwork-io/terragrunt/internal/clihelper" ) const ( CommandName = "version" ) func NewCommand() *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Show terragrunt version.", Hidden: true, DisabledErrorOnUndefinedFlag: true, Action: func(ctx context.Context, cliCtx *clihelper.Context) error { return clihelper.NewExitError(Action(ctx, cliCtx), 0) }, } } func Action(ctx context.Context, cliCtx *clihelper.Context) error { return clihelper.ShowVersion(ctx, cliCtx) } ================================================ FILE: internal/cli/flags/deprecated_flag.go ================================================ package flags import ( "context" "fmt" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" ) var _ = clihelper.Flag(new(DeprecatedFlag)) // DeprecatedFlags are multiple of DeprecatedFlag flags. type DeprecatedFlags []*DeprecatedFlag // DeprecatedFlag represents a deprecated flag that is not shown in the CLI help, but its names, envVars, are registered. type DeprecatedFlag struct { clihelper.Flag newValueFn NewValueFunc controls strict.Controls names []string envVars []string allowedSubcommandScope bool } // GetHidden implements `clihelper.Flag` interface. func (flag *DeprecatedFlag) GetHidden() bool { return true } // AllowedSubcommandScope implements `clihelper.Flag` interface. func (flag *DeprecatedFlag) AllowedSubcommandScope() bool { return flag.allowedSubcommandScope } // GetEnvVars implements `clihelper.Flag` interface. func (flag *DeprecatedFlag) GetEnvVars() []string { return flag.envVars } // Names implements `clihelper.Flag` interface. func (flag *DeprecatedFlag) Names() []string { return flag.names } // Evaluate returns an error if the one of the controls is enabled otherwise logs warning messages and returns nil. func (flag *DeprecatedFlag) Evaluate(ctx context.Context) error { return flag.controls.Evaluate(ctx) } // SetStrictControls creates a strict control for the flag and registers it. func (flag *DeprecatedFlag) SetStrictControls(mainFlag *Flag, regControlsFn RegisterStrictControlsFunc) { if regControlsFn == nil { return } var newValue string if flag.newValueFn != nil { newValue = flag.newValueFn(nil) } flagNameControl := controls.NewDeprecatedFlagName(flag, mainFlag, newValue) envVarControl := controls.NewDeprecatedEnvVar(flag, mainFlag, newValue) if ok := regControlsFn(flagNameControl, envVarControl); ok { flag.controls = strict.Controls{flagNameControl, envVarControl} } } // NewValueFunc represents a function that returns a new value for the current flag if a deprecated flag is called. // Used when the current flag and the deprecated flag are of different types. For example, the string `log-format` flag // must be set to `json` when deprecated bool `terragrunt-json-log` flag is used. More examples: // // terragrunt-disable-log-formatting replaced with: log-format=key-value // terragrunt-json-log replaced with: log-format=json // terragrunt-tf-logs-to-json replaced with: log-format=json type NewValueFunc func(flagValue clihelper.FlagValue) string // NewValue returns a callback function that is used to get a new value for the current flag. func NewValue(val string) NewValueFunc { return func(_ clihelper.FlagValue) string { return val } } // RegisterStrictControlsFunc represents a callback func that registers the given controls in the `opts.StrictControls` stict control tree . type RegisterStrictControlsFunc func(flagNameControl, envVarControl strict.Control) bool // StrictControlsByCommand returns a callback function that adds the taken controls as subcontrols for the given `controlNames`. // Using the given `commandName` as categories. func StrictControlsByCommand(strictControls strict.Controls, commandName string, controlNames ...string) RegisterStrictControlsFunc { return func(flagNameControl, envVarControl strict.Control) bool { flagNamesCategory := fmt.Sprintf(controls.CommandFlagsCategoryNameFmt, commandName) envVarsCategory := fmt.Sprintf(controls.CommandEnvVarsCategoryNameFmt, commandName) return registerStrictControls(strictControls, flagNameControl, envVarControl, flagNamesCategory, envVarsCategory, controlNames...) } } // StrictControlsByGlobalFlags returns a callback function that adds the taken controls as subcontrols for the given `controlNames`. // And assigns the "Global Flag" category to these controls. func StrictControlsByGlobalFlags(strictControls strict.Controls, controlNames ...string) RegisterStrictControlsFunc { return func(flagNameControl, envVarControl strict.Control) bool { return registerStrictControls(strictControls, flagNameControl, envVarControl, controls.GlobalFlagsCategoryName, controls.GlobalEnvVarsCategoryName, controlNames...) } } func registerStrictControls(strictControls strict.Controls, flagNameControl, envVarControl strict.Control, flagNamesCategory, envVarsCategory string, controlNames ...string) bool { if strictControls == nil { return false } if flagNameControl != nil { strictControls.FilterByNames(append( controlNames, controls.TerragruntPrefixFlags, controls.DeprecatedFlags, )...).AddSubcontrolsToCategory(flagNamesCategory, flagNameControl) } if envVarControl != nil { strictControls.FilterByNames(append( controlNames, controls.TerragruntPrefixEnvVars, controls.DeprecatedEnvVars, )...).AddSubcontrolsToCategory(envVarsCategory, envVarControl) } return true } ================================================ FILE: internal/cli/flags/error_handler.go ================================================ package flags import ( "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) // ErrorHandler returns `FlagErrHandlerFunc` which takes a flag parsing error // and tries to suggest the correct command to use with this flag. Otherwise returns the error as is. func ErrorHandler(commands clihelper.Commands) clihelper.FlagErrHandlerFunc { return func(ctx *clihelper.Context, err error) error { var undefinedFlagErr clihelper.UndefinedFlagError if !errors.As(err, &undefinedFlagErr) { return err } undefFlag := string(undefinedFlagErr) if cmds, flag := findFlagInCommands(commands, undefFlag); cmds != nil { var ( flagHint = util.FirstNonEmpty(flag.Names()) cmdHint = strings.Join(cmds.Names(), " ") ) if ctx.Parent().Command != nil { return NewCommandFlagHintError(ctx.Command.Name, undefFlag, cmdHint, flagHint) } return NewGlobalFlagHintError(undefFlag, cmdHint, flagHint) } if isRunContext(ctx) { return NewPassthroughFlagHintError(undefFlag) } return err } } // maxContextDepth is the upper bound on parent traversal in isRunContext // to guard against unexpectedly deep or circular context chains. const maxContextDepth = 10 // isRunContext returns true if the current command or any ancestor is the "run" command. func isRunContext(ctx *clihelper.Context) bool { for range maxContextDepth { if ctx == nil { return false } if ctx.Command != nil && ctx.Command.Name == "run" { return true } ctx = ctx.Parent() } return false } func findFlagInCommands(commands clihelper.Commands, undefFlag string) (clihelper.Commands, clihelper.Flag) { if len(commands) == 0 { return nil, nil } for _, cmd := range commands { for _, flag := range cmd.Flags { flagNames := flag.Names() if flag, ok := flag.(interface{ DeprecatedNames() []string }); ok { flagNames = append(flagNames, flag.DeprecatedNames()...) } if slices.Contains(flagNames, undefFlag) { return clihelper.Commands{cmd}, flag } } if cmds, flag := findFlagInCommands(cmd.Subcommands, undefFlag); cmds != nil { return append(clihelper.Commands{cmd}, cmds...), flag } } return nil, nil } ================================================ FILE: internal/cli/flags/error_handler_test.go ================================================ package flags_test import ( "errors" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" ) func TestErrorHandler(t *testing.T) { t.Parallel() // Setup commands the error handler will search for flag hints. commands := clihelper.Commands{ { Name: "catalog", Flags: clihelper.Flags{ &clihelper.BoolFlag{Name: "no-include-root", Destination: new(bool)}, }, }, { Name: "stack", Subcommands: clihelper.Commands{ { Name: "output", Flags: clihelper.Flags{ &clihelper.BoolFlag{Name: "raw", Destination: new(bool)}, }, }, }, }, } handler := flags.ErrorHandler(commands) // newRootCtx creates a context at the root (global) level, // where ctx.Parent().Command is nil. newRootCtx := func() *clihelper.Context { app := clihelper.NewApp() appCtx := clihelper.NewAppContext(app, nil) rootCmd := &clihelper.Command{Name: "terragrunt", IsRoot: true} return appCtx.NewCommandContext(rootCmd, nil) } // newCommandCtx creates a context for a named subcommand, // where ctx.Parent().Command is the root command (non-nil). newCommandCtx := func(name string) *clihelper.Context { app := clihelper.NewApp() appCtx := clihelper.NewAppContext(app, nil) rootCmd := &clihelper.Command{Name: "terragrunt", IsRoot: true} rootCtx := appCtx.NewCommandContext(rootCmd, nil) cmd := &clihelper.Command{Name: name} return rootCtx.NewCommandContext(cmd, nil) } // newRunSubcommandCtx creates a context for a subcommand of "run" // (e.g., "providers" in "terragrunt run providers lock -platform ..."). newRunSubcommandCtx := func(name string) *clihelper.Context { app := clihelper.NewApp() appCtx := clihelper.NewAppContext(app, nil) rootCmd := &clihelper.Command{Name: "terragrunt", IsRoot: true} rootCtx := appCtx.NewCommandContext(rootCmd, nil) runCmd := &clihelper.Command{Name: "run"} runCtx := rootCtx.NewCommandContext(runCmd, nil) cmd := &clihelper.Command{Name: name} return runCtx.NewCommandContext(cmd, nil) } testCases := []struct { ctx *clihelper.Context err error expectedError error name string }{ { name: "non-undefined-flag error passes through unchanged", ctx: newRootCtx(), err: errors.New("some other error"), expectedError: errors.New("some other error"), }, { name: "known flag at global level returns GlobalFlagHintError", ctx: newRootCtx(), err: clihelper.UndefinedFlagError("raw"), expectedError: flags.NewGlobalFlagHintError("raw", "stack output", "raw"), }, { name: "known flag at command level returns CommandFlagHintError", ctx: newCommandCtx("run"), err: clihelper.UndefinedFlagError("no-include-root"), expectedError: flags.NewCommandFlagHintError("run", "no-include-root", "catalog", "no-include-root"), }, { name: "unknown flag on run command returns PassthroughFlagHintError", ctx: newCommandCtx("run"), err: clihelper.UndefinedFlagError("platform"), expectedError: flags.NewPassthroughFlagHintError("platform"), }, { name: "unknown flag on run subcommand returns PassthroughFlagHintError", ctx: newRunSubcommandCtx("providers"), err: clihelper.UndefinedFlagError("platform"), expectedError: flags.NewPassthroughFlagHintError("platform"), }, { name: "unknown flag on non-run command returns original error", ctx: newCommandCtx("catalog"), err: clihelper.UndefinedFlagError("platform"), expectedError: clihelper.UndefinedFlagError("platform"), }, { name: "unknown flag at global level returns original error", ctx: newRootCtx(), err: clihelper.UndefinedFlagError("platform"), expectedError: clihelper.UndefinedFlagError("platform"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := handler(tc.ctx, tc.err) assert.EqualError(t, result, tc.expectedError.Error()) }) } } ================================================ FILE: internal/cli/flags/errors.go ================================================ package flags import "fmt" var _ error = new(GlobalFlagHintError) type GlobalFlagHintError struct { undefFlag string cmdHint string flagHint string } func NewGlobalFlagHintError(undefFlag, cmdHint, flagHint string) *GlobalFlagHintError { return &GlobalFlagHintError{ undefFlag: undefFlag, cmdHint: cmdHint, flagHint: flagHint, } } func (err GlobalFlagHintError) Error() string { return fmt.Sprintf("flag `--%s` is not a valid global flag. Did you mean to use `%s --%s`?", err.undefFlag, err.cmdHint, err.flagHint) } var _ error = new(CommandFlagHintError) type CommandFlagHintError struct { undefFlag string wrongCmd string cmdHint string flagHint string } func NewCommandFlagHintError(wrongCmd, undefFlag, cmdHint, flagHint string) *CommandFlagHintError { return &CommandFlagHintError{ undefFlag: undefFlag, wrongCmd: wrongCmd, cmdHint: cmdHint, flagHint: flagHint, } } func (err CommandFlagHintError) Error() string { return fmt.Sprintf("flag `--%s` is not a valid flag for `%s`. Did you mean to use `%s --%s`?", err.undefFlag, err.wrongCmd, err.cmdHint, err.flagHint) } var _ error = new(PassthroughFlagHintError) type PassthroughFlagHintError struct { undefFlag string } func NewPassthroughFlagHintError(undefFlag string) *PassthroughFlagHintError { return &PassthroughFlagHintError{undefFlag: undefFlag} } func (err PassthroughFlagHintError) Error() string { return fmt.Sprintf( "flag `-%s` is not a Terragrunt flag. If this is an OpenTofu/Terraform flag, use `--` to forward it (e.g., `terragrunt run -- -%s`).", err.undefFlag, err.undefFlag, ) } ================================================ FILE: internal/cli/flags/flag.go ================================================ // Package flags provides tools that are used by all commands to create deprecation flags with strict controls. package flags import ( "context" "flag" "io" "strconv" "strings" "github.com/gruntwork-io/terragrunt/internal/clihelper" ) var _ = clihelper.Flag(new(Flag)) // EvaluateWrapperFunc represents a function that is used to wrap the `Evaluate(ctx context.Context) error` strict control method. // Which can be passed as an option `WithEvaluateWrapper` to `NewFlag(...)` to control the behavior of strict control evaluation. type EvaluateWrapperFunc func(ctx context.Context, evalFn func(ctx context.Context) error) error // Flag is a wrapper for `clihelper.Flag` that avoids displaying deprecated flags in help, but registers their flag names and environment variables. type Flag struct { clihelper.Flag evaluateWrapper EvaluateWrapperFunc deprecatedFlags DeprecatedFlags } // NewFlag returns a new Flag instance. func NewFlag(new clihelper.Flag, opts ...Option) *Flag { flag := &Flag{ Flag: new, evaluateWrapper: func(ctx context.Context, evalFn func(ctx context.Context) error) error { return evalFn(ctx) }, } for _, opt := range opts { opt(flag) } return flag } // TakesValue implements `github.com/urfave/clihelper.DocGenerationFlag` required to generate help. // TakesValue returns `true` for all flags except boolean ones that are `false` or `true` inverted. func (newFlag *Flag) TakesValue() bool { if newFlag.Flag.Value() == nil { return false } val, ok := newFlag.Flag.Value().Get().(bool) if newFlag.Flag.Value().IsNegativeBoolFlag() { val = !val } return !ok || !val } // DeprecatedNames returns all deprecated names for this flag. func (newFlag *Flag) DeprecatedNames() []string { var names []string if flag, ok := newFlag.Flag.(interface{ DeprecatedNames() []string }); ok { names = flag.DeprecatedNames() } for _, deprecated := range newFlag.deprecatedFlags { names = append(names, deprecated.Names()...) } return names } // Value implements `clihelper.Flag` interface. func (newFlag *Flag) Value() clihelper.FlagValue { for _, deprecatedFlag := range newFlag.deprecatedFlags { if deprecatedFlag.Flag == newFlag.Flag { continue } if deprecatedFlagValue := deprecatedFlag.Value(); deprecatedFlagValue != nil && deprecatedFlagValue.IsSet() { newValue := deprecatedFlagValue.String() if newFlag.Flag.Value().IsNegativeBoolFlag() && deprecatedFlagValue.IsBoolFlag() { if v, ok := deprecatedFlagValue.Get().(bool); ok { newValue = strconv.FormatBool(!v) } } if deprecatedFlag.newValueFn != nil { newValue = deprecatedFlag.newValueFn(deprecatedFlagValue) } newFlag.Flag.Value().Getter(deprecatedFlagValue.GetName()).Set(newValue) //nolint:errcheck } } return newFlag.Flag.Value() } // Apply implements `clihelper.Flag` interface. func (newFlag *Flag) Apply(set *flag.FlagSet) error { if err := newFlag.Flag.Apply(set); err != nil { return err } for _, deprecated := range newFlag.deprecatedFlags { if deprecated.Flag == newFlag.Flag { if err := clihelper.ApplyFlag(deprecated, set); err != nil { return err } continue } if err := deprecated.Apply(set); err != nil { return err } } return nil } // RunAction implements `clihelper.Flag` interface. func (newFlag *Flag) RunAction(ctx context.Context, cliCtx *clihelper.Context) error { for _, deprecated := range newFlag.deprecatedFlags { if err := newFlag.evaluateWrapper(ctx, deprecated.Evaluate); err != nil { return err } if deprecated.Flag == nil || deprecated.Flag == newFlag.Flag || !deprecated.Value().IsSet() { continue } if err := deprecated.RunAction(ctx, cliCtx); err != nil { return err } } if deprecated, ok := newFlag.Flag.(interface { Evaluate(ctx context.Context) error }); ok { if err := newFlag.evaluateWrapper(ctx, deprecated.Evaluate); err != nil { return err } } return newFlag.Flag.RunAction(ctx, cliCtx) } // Parse parses the given `args` for the flag value and env vars values specified in the flag. // The value will be assigned to the `Destination` field. // The value can also be retrieved using `flag.Value().Get()`. func (newFlag *Flag) Parse(args clihelper.Args) error { flagSet := flag.NewFlagSet("", flag.ContinueOnError) flagSet.SetOutput(io.Discard) if err := newFlag.Apply(flagSet); err != nil { return err } const maxFlagsParse = 1000 // Maximum flags parse for range maxFlagsParse { err := flagSet.Parse(args) if err == nil { break } if errStr := err.Error(); !strings.HasPrefix(errStr, clihelper.ErrMsgFlagUndefined) { break } args = flagSet.Args() } return nil } ================================================ FILE: internal/cli/flags/flag_opts.go ================================================ package flags import ( "github.com/gruntwork-io/terragrunt/internal/clihelper" ) // Option is used to set options to the `Flag`. type Option func(*Flag) // WithDeprecatedFlag returns an `Option` that will register the given `deprecatedFlag` as a deprecated flag. // `newValueFn` is called to get a value for the new flag when this deprecated flag triggers. For example: // // NewFlag(&clihelper.GenericFlag[string]{ // Name: "log-format", // }, WithDeprecatedFlag(&clihelper.BoolFlag{ // Name: "terragrunt-json-log", // }, flags.NewValue("json"), nil)) func WithDeprecatedFlag(deprecatedFlag clihelper.Flag, newValueFn NewValueFunc, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: deprecatedFlag, newValueFn: newValueFn, allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedPrefix returns an `Option` that will create a deprecated flag with the same name as the new flag, // but with the specified `prefix` prepended to the names and environment variables. // Should be used with caution, as changing the name of the new flag will change the name of the deprecated flag. // For example: // // NewFlag(&clihelper.GenericFlag[string]{ // Name: "no-color", // Aliases: []string{"disable-color"}, // EnvVars: []string{"NO_COLOR","DISABLE_COLOR"}, // }, WithDeprecatedPrefix(Prefix{"terragrunt"}, nil)) // // The deprecated flag will have "terragrunt-no-color","terragrunt-disable-color" names and "TERRAGRUNT_NO_COLOR","TERRAGRUNT_DISABLE_COLOR" env vars. // NOTE: This function is currently unused but retained for future flag deprecation needs. func WithDeprecatedPrefix(prefix Prefix, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: newFlag.Flag, names: prefix.FlagNames(newFlag.Names()...), envVars: prefix.EnvVars(newFlag.Names()...), allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedNames returns an `Option` that will create a deprecated flag. // The given `flagNames` names will assign both names (converting to lowercase,dash) // and env vars (converting to uppercase,underscore). For example: // // WithDeprecatedNames([]string{"NO_COLOR", "working-dir"}, nil) // // The deprecated flag will have "no-color","working-dir" names and "NO_COLOR","WORKING_DIR" env vars. func WithDeprecatedNames(flagNames []string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: newFlag.Flag, names: Prefix{}.FlagNames(flagNames...), envVars: Prefix{}.EnvVars(flagNames...), allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedName does the same as `WithDeprecatedNames`, but with a single name. func WithDeprecatedName(flagName string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { WithDeprecatedNames([]string{flagName}, regControlsFn)(newFlag) } } // WithDeprecatedNamesEnvVars returns an `Option` that will create a deprecated flag, // with the given `flagNames`, `envVars` assigned to the flag names and environment variables as is. func WithDeprecatedNamesEnvVars(flagNames, envVars []string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: newFlag.Flag, names: flagNames, envVars: envVars, allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedEnvVars returns an `Option` that will create a flag with the given deprecated env vars. func WithDeprecatedEnvVars(envVars []string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: newFlag.Flag, envVars: envVars, allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedFlagNames returns an `Option` that will create a flag with the given deprecated flag names. func WithDeprecatedFlagNames(flagNames []string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { deprecatedFlag := &DeprecatedFlag{ Flag: newFlag.Flag, names: flagNames, allowedSubcommandScope: true, } deprecatedFlag.SetStrictControls(newFlag, regControlsFn) newFlag.deprecatedFlags = append(newFlag.deprecatedFlags, deprecatedFlag) } } // WithDeprecatedFlagName does the same as `WithDeprecatedFlagNames`, but with a single name. func WithDeprecatedFlagName(flagName string, regControlsFn RegisterStrictControlsFunc) Option { return func(newFlag *Flag) { WithDeprecatedFlagNames([]string{flagName}, regControlsFn)(newFlag) } } // WithEvaluateWrapper returns an Option that wraps the strict control `Evaluate(ctx context.Context)` function. func WithEvaluateWrapper(fn EvaluateWrapperFunc) Option { return func(newFlag *Flag) { newFlag.evaluateWrapper = fn } } ================================================ FILE: internal/cli/flags/flag_test.go ================================================ package flags_test import ( "bytes" "flag" "fmt" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockDestValue[T any](val T) *T { return &val } func newLogger() (log.Logger, *bytes.Buffer) { formatter := format.NewFormatter(placeholders.Placeholders{placeholders.Message()}) output := new(bytes.Buffer) logger := log.New(log.WithOutput(output), log.WithLevel(log.InfoLevel), log.WithFormatter(formatter)) return logger, output } func TestFlag_TakesValue(t *testing.T) { t.Parallel() testCases := []struct { flag clihelper.Flag expected bool }{ { &clihelper.BoolFlag{Name: "name", Destination: mockDestValue(false)}, true, }, { &clihelper.BoolFlag{Name: "name", Destination: mockDestValue(true)}, false, }, { &clihelper.BoolFlag{Name: "name", Negative: true, Destination: mockDestValue(true)}, true, }, { &clihelper.BoolFlag{Name: "name", Negative: true, Destination: mockDestValue(false)}, false, }, { &clihelper.GenericFlag[string]{Name: "name", Destination: mockDestValue("value")}, true, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testFlag := flags.NewFlag(tc.flag) err := testFlag.Apply(new(flag.FlagSet)) require.NoError(t, err) assert.Equal(t, tc.expected, testFlag.TakesValue()) }) } } func TestFlag_Evaluate(t *testing.T) { t.Parallel() mockRegControls := func(flagNameControl, envVarControl strict.Control) bool { return true } deprecatedFlagWarning := func() string { return controls.NewDeprecatedFlagName(&clihelper.BoolFlag{}, &clihelper.BoolFlag{}, "").WarningFmt } deprecatedEnvVarWarning := func() string { return controls.NewDeprecatedEnvVar(&clihelper.BoolFlag{}, &clihelper.BoolFlag{}, "").WarningFmt } type testCaseFlag struct { flag *flags.Flag arg string envVar string } testCases := []struct { flags []testCaseFlag expectedOutput []string }{ { []testCaseFlag{ { flags.NewFlag( &clihelper.BoolFlag{Name: "new-flag-name"}, flags.WithDeprecatedName("old-flag-name", mockRegControls), ), "old-flag-name", "", }, { flags.NewFlag( &clihelper.BoolFlag{Name: "new-env-var-name", EnvVars: []string{"NEW_ENV_VAR_NAME"}}, flags.WithDeprecatedName("old-env-var-name", mockRegControls), ), "", "OLD_ENV_VAR_NAME", }, }, []string{ fmt.Sprintf(deprecatedFlagWarning(), "old-flag-name", "new-flag-name"), fmt.Sprintf(deprecatedEnvVarWarning(), "OLD_ENV_VAR_NAME", "NEW_ENV_VAR_NAME=true"), }, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() logger, output := newLogger() ctx := t.Context() ctx = log.ContextWithLogger(ctx, logger) for _, testFlag := range tc.flags { err := testFlag.flag.Apply(new(flag.FlagSet)) require.NoError(t, err) if testFlag.arg != "" { err := testFlag.flag.Value().Getter(testFlag.arg).Set("1") require.NoError(t, err) } if testFlag.envVar != "" { err := testFlag.flag.Value().Getter(testFlag.envVar).EnvSet("1") require.NoError(t, err) } err = testFlag.flag.RunAction(ctx, clihelper.NewAppContext(nil, nil)) require.NoError(t, err) } outputLines := strings.Split(strings.TrimSpace(output.String()), "\n") assert.Equal(t, tc.expectedOutput, outputLines) }) } } ================================================ FILE: internal/cli/flags/global/flags.go ================================================ // Package global provides CLI global flags. package global import ( "context" "fmt" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/cli/commands/help" "github.com/gruntwork-io/terragrunt/internal/cli/commands/version" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( // Logs related flags. LogLevelFlagName = "log-level" LogDisableFlagName = "log-disable" ShowLogAbsPathsFlagName = "log-show-abs-paths" LogFormatFlagName = "log-format" LogCustomFormatFlagName = "log-custom-format" NoColorFlagName = "no-color" NonInteractiveFlagName = "non-interactive" WorkingDirFlagName = "working-dir" // Strict Mode related flags. StrictModeFlagName = "strict-mode" StrictControlFlagName = "strict-control" // Experiment Mode related flags/envs. ExperimentModeFlagName = "experiment-mode" ExperimentFlagName = "experiment" // Tips related flags. NoTipsFlagName = "no-tips" NoTipFlagName = "no-tip" // App flags. HelpFlagName = "help" VersionFlagName = "version" // Telemetry flags. TelemetryTraceExporterFlagName = "telemetry-trace-exporter" TelemetryTraceExporterInsecureEndpointFlagName = "telemetry-trace-exporter-insecure-endpoint" TelemetryTraceExporterHTTPEndpointFlagName = "telemetry-trace-exporter-http-endpoint" TraceparentFlagName = "traceparent" TelemetryMetricExporterFlagName = "telemetry-metric-exporter" TelemetryMetricExporterInsecureEndpointFlagName = "telemetry-metric-exporter-insecure-endpoint" // Renamed flags. DeprecatedLogLevelFlagName = "log-level" DeprecatedLogDisableFlagName = "log-disable" DeprecatedShowLogAbsPathsFlagName = "log-show-abs-paths" DeprecatedLogFormatFlagName = "log-format" DeprecatedLogCustomFormatFlagName = "log-custom-format" DeprecatedNoColorFlagName = "no-color" DeprecatedNonInteractiveFlagName = "non-interactive" DeprecatedTFInputFlagName = "tf-input" DeprecatedWorkingDirFlagName = "working-dir" DeprecatedStrictModeFlagName = "strict-mode" DeprecatedStrictControlFlagName = "strict-control" DeprecatedExperimentModeFlagName = "experiment-mode" DeprecatedExperimentFlagName = "experiment" // Deprecated flags. DeprecatedDisableLogFormattingFlagName = "disable-log-formatting" DeprecatedJSONLogFlagName = "json-log" DeprecatedTfLogJSONFlagName = "tf-logs-to-json" ) // NewFlags creates and returns global flags common for all commands. func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) legacyLogsControl := flags.StrictControlsByGlobalFlags(opts.StrictControls, controls.LegacyLogs) flags := clihelper.Flags{ NewLogLevelFlag(l, opts, prefix), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: WorkingDirFlagName, EnvVars: tgPrefix.EnvVars(WorkingDirFlagName), Destination: &opts.WorkingDir, Usage: "The path to the directory of Terragrunt configurations. Default is current directory.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedWorkingDirFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: LogDisableFlagName, EnvVars: tgPrefix.EnvVars(LogDisableFlagName), Usage: "Disable logging.", Setter: func(val bool) error { l.Formatter().SetDisabledOutput(val) if val { opts.ForwardTFStdout = true } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedLogDisableFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: ShowLogAbsPathsFlagName, EnvVars: tgPrefix.EnvVars(ShowLogAbsPathsFlagName), Destination: &opts.Writers.LogShowAbsPaths, Usage: "Show absolute paths in logs.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedShowLogAbsPathsFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NoColorFlagName, EnvVars: tgPrefix.EnvVars(NoColorFlagName), Usage: "Disable color output.", Setter: func(val bool) error { l.Formatter().SetDisabledColors(val) return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedNoColorFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: LogFormatFlagName, EnvVars: tgPrefix.EnvVars(LogFormatFlagName), Usage: "Set the log format.", Setter: l.Formatter().SetFormat, Action: func(_ context.Context, _ *clihelper.Context, val string) error { switch val { case format.BareFormatName: opts.ForwardTFStdout = true case format.JSONFormatName: opts.JSONLogFormat = true } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedLogFormatFlagName), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedDisableLogFormattingFlagName), legacyLogsControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedJSONLogFlagName), legacyLogsControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedTfLogJSONFlagName), legacyLogsControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ Name: LogCustomFormatFlagName, EnvVars: tgPrefix.EnvVars(LogCustomFormatFlagName), Usage: "Set the custom log formatting.", Setter: l.Formatter().SetCustomFormat, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedLogCustomFormatFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ Name: NonInteractiveFlagName, EnvVars: tgPrefix.EnvVars(NonInteractiveFlagName), Destination: &opts.NonInteractive, Usage: `Assume "yes" for all prompts.`, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedNonInteractiveFlagName), terragruntPrefixControl), flags.WithDeprecatedFlag(&clihelper.BoolFlag{ Negative: true, EnvVars: flags.Prefix{}.EnvVars(DeprecatedTFInputFlagName), }, nil, terragruntPrefixControl)), // Experiment Mode flags. flags.NewFlag(&clihelper.BoolFlag{ Name: ExperimentModeFlagName, EnvVars: tgPrefix.EnvVars(ExperimentModeFlagName), Usage: "Enables experiment mode for Terragrunt. For more information, see https://docs.terragrunt.com/reference/experiment-mode .", Setter: func(_ bool) error { opts.Experiments.ExperimentMode() return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedExperimentModeFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: ExperimentFlagName, EnvVars: tgPrefix.EnvVars(ExperimentFlagName), Usage: "Enables specific experiments. For a list of available experiments, see https://docs.terragrunt.com/reference/experiment-mode .", Setter: opts.Experiments.EnableExperiment, Action: func(_ context.Context, _ *clihelper.Context, _ []string) error { opts.Experiments.NotifyCompletedExperiments(l) return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedExperimentFlagName), terragruntPrefixControl)), // Tips Mode flags. flags.NewFlag(&clihelper.BoolFlag{ Name: NoTipsFlagName, EnvVars: tgPrefix.EnvVars(NoTipsFlagName), Usage: "Disable all tips from being displayed.", Setter: func(v bool) error { if v { opts.Tips.DisableAll() } return nil }, }), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: NoTipFlagName, EnvVars: tgPrefix.EnvVars(NoTipFlagName), Usage: "Disable specific tips from being displayed.", Setter: opts.Tips.DisableTip, }), // Strict Mode flags. flags.NewFlag(&clihelper.BoolFlag{ Name: StrictModeFlagName, EnvVars: tgPrefix.EnvVars(StrictModeFlagName), Usage: "Enables strict mode for Terragrunt. For more information, run 'terragrunt info strict'.", Setter: func(_ bool) error { opts.StrictControls.FilterByStatus(strict.ActiveStatus).Enable() return nil }, Action: func(_ context.Context, _ *clihelper.Context, _ bool) error { opts.StrictControls.LogEnabled(l) return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedStrictModeFlagName), terragruntPrefixControl)), flags.NewFlag(&clihelper.SliceFlag[string]{ Name: StrictControlFlagName, EnvVars: tgPrefix.EnvVars(StrictControlFlagName), Usage: "Enables specific strict controls. For a list of available controls, run 'terragrunt info strict'.", Setter: func(val string) error { return opts.StrictControls.EnableControl(val) }, Action: func(_ context.Context, _ *clihelper.Context, vals []string) error { opts.StrictControls.LogEnabled(l) opts.StrictControls.LogCompletedControls(l, vals) return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedStrictControlFlagName), terragruntPrefixControl)), } flags = flags.Add(NewTelemetryFlags(opts, nil)...) flags = flags.Sort() flags = flags.Add(NewHelpVersionFlags(l, opts)...) return flags } // NewTelemetryFlags creates telemetry related flags. func NewTelemetryFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ EnvVars: tgPrefix.EnvVars(TelemetryTraceExporterFlagName), Destination: &opts.Telemetry.TraceExporter, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemetry-trace-exporter"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemerty-trace-exporter"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ EnvVars: tgPrefix.EnvVars(TelemetryTraceExporterInsecureEndpointFlagName), Destination: &opts.Telemetry.TraceExporterInsecureEndpoint, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemetry-trace-exporter-insecure-endpoint"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemerty-trace-exporter-insecure-endpoint"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ EnvVars: tgPrefix.EnvVars(TelemetryTraceExporterHTTPEndpointFlagName), Destination: &opts.Telemetry.TraceExporterHTTPEndpoint, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemetry-trace-exporter-http-endpoint"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemerty-trace-exporter-http-endpoint"), terragruntPrefixControl)), flags.NewFlag(&clihelper.GenericFlag[string]{ EnvVars: flags.Prefix{}.EnvVars(TraceparentFlagName), Destination: &opts.Telemetry.TraceParent, }), flags.NewFlag(&clihelper.GenericFlag[string]{ EnvVars: tgPrefix.EnvVars(TelemetryMetricExporterFlagName), Destination: &opts.Telemetry.MetricExporter, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemetry-metric-exporter"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemerty-metric-exporter"), terragruntPrefixControl)), flags.NewFlag(&clihelper.BoolFlag{ EnvVars: tgPrefix.EnvVars(TelemetryMetricExporterInsecureEndpointFlagName), Destination: &opts.Telemetry.MetricExporterInsecureEndpoint, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemetry-metric-exporter-insecure-endpoint"), terragruntPrefixControl), flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("telemerty-metric-exporter-insecure-endpoint"), terragruntPrefixControl)), } } func NewLogLevelFlag(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return flags.NewFlag(&clihelper.GenericFlag[string]{ Name: LogLevelFlagName, EnvVars: tgPrefix.EnvVars(LogLevelFlagName), DefaultText: l.Level().String(), Setter: l.SetLevel, Usage: fmt.Sprintf("Sets the logging level for Terragrunt. Supported levels: %s.", log.AllLevels), Action: func(_ context.Context, _ *clihelper.Context, val string) error { // Before the release of v0.67.0, these levels actually disabled logs, since we do not use these levels for logging. // For backward compatibility we simulate the same behavior. removedLevels := []string{ "panic", "fatal", } if collections.ListContainsElement(removedLevels, val) { opts.ForwardTFStdout = true l.Formatter().SetDisabledOutput(true) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars(DeprecatedLogLevelFlagName), terragruntPrefixControl)) } func NewHelpVersionFlags(l log.Logger, opts *options.TerragruntOptions) clihelper.Flags { return clihelper.Flags{ &clihelper.BoolFlag{ Name: HelpFlagName, // --help, -help Aliases: []string{"h"}, // -h Usage: "Show help.", Action: func(ctx context.Context, cliCtx *clihelper.Context, _ bool) error { return help.Action(ctx, cliCtx, l, opts) }, }, &clihelper.BoolFlag{ Name: VersionFlagName, // --version, -version Aliases: []string{"v"}, // -v Usage: "Show terragrunt version.", Action: func(ctx context.Context, cliCtx *clihelper.Context, _ bool) (err error) { return version.Action(ctx, cliCtx) }, }, } } ================================================ FILE: internal/cli/flags/prefix.go ================================================ package flags import ( "strings" ) const ( // TgPrefix is an environment variable prefix. TgPrefix = "TG" // TerragruntPrefix is an environment variable deprecated prefix. TerragruntPrefix = "TERRAGRUNT" ) // Prefix helps to combine strings into flag names or environment variables in a convenient way. // Can be passed to subcommands and contain the names of parent commands, // thus creating env vars as a chain of "TG prefix, parent command names, command name, flag name". For example: // `TG_HLC_FMT_FILE`, where `hcl` is the parent command, `fmt` is the command and `file` is a flag. Example of use: // // func main () { // ParentCommand(Prefix{TgPrefix}) // } // // func ParentCommand(prefix Prefix) { // Command(prefix.Append("hcl")) // } // // func Command(prefix Prefix) { // Flag(prefix.Append("fmt")) // } // // func Flag(prefix Prefix) { // envName := prefix.EnvVar("file") // TG_HCL_FMT_FILE // } type Prefix []string // Prepend adds a value to the beginning of the slice. func (prefix Prefix) Prepend(val string) Prefix { return append([]string{val}, prefix...) } // Append adds a value to the end of the slice. func (prefix Prefix) Append(val string) Prefix { return append(prefix, val) } // EnvVar returns a string that is the concatenation of the slice values with the given `name`, // using underscores as separators, replacing dashes with underscores, converting to uppercase. func (prefix Prefix) EnvVar(name string) string { if name == "" { return "" } name = strings.Join(append(prefix, name), "_") return strings.ToUpper(strings.ReplaceAll(name, "-", "_")) } // EnvVars does the same `EnvVar`, except it takes and returns the slice. func (prefix Prefix) EnvVars(names ...string) []string { var envVars = make([]string, len(names)) for i := range names { envVars[i] = prefix.EnvVar(names[i]) } return envVars } // FlagName returns a string that is the concatenation of the slice values with the given `name`, // using dashes as separators, replacing dashes with underscores, converting to lowercase. func (prefix Prefix) FlagName(name string) string { if name == "" { return "" } name = strings.Join(append(prefix, name), "-") return strings.ToLower(strings.ReplaceAll(name, "_", "-")) } // FlagNames does the same `FlagName`, except it takes and returns the slice. func (prefix Prefix) FlagNames(names ...string) []string { var flagNames = make([]string, len(names)) for i := range names { flagNames[i] = prefix.FlagName(names[i]) } return flagNames } ================================================ FILE: internal/cli/flags/shared/all.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( AllFlagName = "all" AllFlagAlias = "a" ) // NewAllFlag creates the --all flag for running commands across all units in a stack. func NewAllFlag(opts *options.TerragruntOptions, prefix flags.Prefix) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) return flags.NewFlag(&clihelper.BoolFlag{ Name: AllFlagName, Aliases: []string{AllFlagAlias}, EnvVars: tgPrefix.EnvVars(AllFlagName), Destination: &opts.RunAll, Usage: `Run the specified command on the stack of units in the current directory.`, Action: func(_ context.Context, _ *clihelper.Context, _ bool) error { if opts.Graph { return errors.New(new(AllGraphFlagsError)) } return nil }, }) } ================================================ FILE: internal/cli/flags/shared/auth.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( AuthProviderCmdFlagName = "auth-provider-cmd" ) // NewAuthProviderCmdFlag creates a flag for specifying the auth provider command. func NewAuthProviderCmdFlag(opts *options.TerragruntOptions, prefix flags.Prefix, commandName string) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) var terragruntPrefixControl flags.RegisterStrictControlsFunc if commandName != "" { terragruntPrefixControl = flags.StrictControlsByCommand(opts.StrictControls, commandName) } else { terragruntPrefixControl = flags.StrictControlsByGlobalFlags(opts.StrictControls) } return flags.NewFlag( &clihelper.GenericFlag[string]{ Name: AuthProviderCmdFlagName, EnvVars: tgPrefix.EnvVars(AuthProviderCmdFlagName), Destination: &opts.AuthProviderCmd, Usage: "Run the provided command and arguments to authenticate Terragrunt dynamically when necessary.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("auth-provider-cmd"), terragruntPrefixControl), ) } ================================================ FILE: internal/cli/flags/shared/backend.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( BackendBootstrapFlagName = "backend-bootstrap" BackendRequireBootstrapFlagName = "backend-require-bootstrap" DisableBucketUpdateFlagName = "disable-bucket-update" ) // NewBackendFlags defines backend-related flags that should be available to both `run` and `backend` commands. func NewBackendFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return clihelper.Flags{ flags.NewFlag(&clihelper.BoolFlag{ Name: BackendBootstrapFlagName, EnvVars: tgPrefix.EnvVars(BackendBootstrapFlagName), Destination: &opts.BackendBootstrap, Usage: "Automatically bootstrap backend infrastructure before attempting to use it.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: BackendRequireBootstrapFlagName, EnvVars: tgPrefix.EnvVars(BackendRequireBootstrapFlagName), Destination: &opts.FailIfBucketCreationRequired, Usage: "When this flag is set Terragrunt will fail if the remote state bucket needs to be created.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("fail-on-state-bucket-creation"), terragruntPrefixControl), ), flags.NewFlag(&clihelper.BoolFlag{ Name: DisableBucketUpdateFlagName, EnvVars: tgPrefix.EnvVars(DisableBucketUpdateFlagName), Destination: &opts.DisableBucketUpdate, Usage: "When this flag is set Terragrunt will not update the remote state bucket.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("disable-bucket-update"), terragruntPrefixControl), ), } } ================================================ FILE: internal/cli/flags/shared/config.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( ConfigFlagName = "config" ) // NewConfigFlag creates a flag for specifying the Terragrunt config file path. func NewConfigFlag(opts *options.TerragruntOptions, prefix flags.Prefix, commandName string) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) var terragruntPrefixControl flags.RegisterStrictControlsFunc if commandName != "" { terragruntPrefixControl = flags.StrictControlsByCommand(opts.StrictControls, commandName) } else { terragruntPrefixControl = flags.StrictControlsByGlobalFlags(opts.StrictControls) } return flags.NewFlag( &clihelper.GenericFlag[string]{ Name: ConfigFlagName, EnvVars: tgPrefix.EnvVars(ConfigFlagName), Destination: &opts.TerragruntConfigPath, Usage: "The path to the Terragrunt config file. Default is terragrunt.hcl.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("config"), terragruntPrefixControl), ) } ================================================ FILE: internal/cli/flags/shared/doc.go ================================================ // Package shared provides flags that are shared by multiple commands. // // This package is underutilized right now, as some more serious refactoring is needed to make sure all // shared flags use this package instead of reusing flags from other commands. package shared ================================================ FILE: internal/cli/flags/shared/download.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( DownloadDirFlagName = "download-dir" ) // NewDownloadDirFlag creates a flag for specifying the download directory path. func NewDownloadDirFlag(opts *options.TerragruntOptions, prefix flags.Prefix, commandName string) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) var terragruntPrefixControl flags.RegisterStrictControlsFunc if commandName != "" { terragruntPrefixControl = flags.StrictControlsByCommand(opts.StrictControls, commandName) } else { terragruntPrefixControl = flags.StrictControlsByGlobalFlags(opts.StrictControls) } return flags.NewFlag( &clihelper.GenericFlag[string]{ Name: DownloadDirFlagName, EnvVars: tgPrefix.EnvVars(DownloadDirFlagName), Destination: &opts.DownloadDir, Usage: "The path to download OpenTofu/Terraform modules into. Default is .terragrunt-cache in the working directory.", }, flags.WithDeprecatedEnvVars( append( terragruntPrefix.EnvVars("download"), terragruntPrefix.EnvVars("download-dir")..., ), terragruntPrefixControl, ), ) } ================================================ FILE: internal/cli/flags/shared/errors.go ================================================ package shared // AllGraphFlagsError is returned when both --all and --graph flags are used simultaneously. type AllGraphFlagsError byte func (err *AllGraphFlagsError) Error() string { return "Using the `--all` and `--graph` flags simultaneously is not supported." } ================================================ FILE: internal/cli/flags/shared/failfast.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( FailFastFlagName = "fail-fast" ) // NewFailFastFlag creates the --fail-fast flag for stopping execution on the first error. func NewFailFastFlag(opts *options.TerragruntOptions) *flags.Flag { tgPrefix := flags.Prefix{flags.TgPrefix} return flags.NewFlag(&clihelper.BoolFlag{ Name: FailFastFlagName, EnvVars: tgPrefix.EnvVars(FailFastFlagName), Destination: &opts.FailFast, Usage: "Fail immediately if any unit fails, rather than continuing to process remaining units.", }) } ================================================ FILE: internal/cli/flags/shared/feature.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( FeatureFlagName = "feature" ) // NewFeatureFlags defines the feature flag map that should be available to both `run` and `backend` commands. func NewFeatureFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return clihelper.Flags{ flags.NewFlag(&clihelper.MapFlag[string, string]{ Name: FeatureFlagName, EnvVars: tgPrefix.EnvVars(FeatureFlagName), Usage: "Set feature flags for the HCL code.", // Use default splitting behavior with comma separators via MapFlag defaults Action: func(_ context.Context, _ *clihelper.Context, value map[string]string) error { for key, val := range value { opts.FeatureFlags.Store(key, val) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("feature"), terragruntPrefixControl), ), } } ================================================ FILE: internal/cli/flags/shared/filter.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( FilterFlagName = "filter" FilterAffectedFlagName = "filter-affected" FilterAllowDestroyFlagName = "filter-allow-destroy" FilterFileFlagName = "filters-file" NoFilterFileFlagName = "no-filters-file" ) // NewFilterFlags creates flags for specifying filter queries. func NewFilterFlags(l log.Logger, opts *options.TerragruntOptions) clihelper.Flags { tgPrefix := flags.Prefix{flags.TgPrefix} return clihelper.Flags{ flags.NewFlag( &clihelper.SliceFlag[string]{ Name: FilterFlagName, EnvVars: tgPrefix.EnvVars(FilterFlagName), Usage: "Filter components using filter syntax. Can be specified multiple times for union (OR) semantics.", Action: func(_ context.Context, _ *clihelper.Context, val []string) error { if len(val) == 0 { return nil } parsed, err := filter.ParseFilterQueries(l, val) if err != nil { return err } opts.Filters = append(opts.Filters, parsed...) opts.RunAll = true return nil }, }, ), flags.NewFlag( &clihelper.BoolFlag{ Name: FilterAffectedFlagName, EnvVars: tgPrefix.EnvVars(FilterAffectedFlagName), Usage: "Filter components affected by changes between main and HEAD. Equivalent to --filter=[main...HEAD].", Action: func(ctx context.Context, _ *clihelper.Context, val bool) error { if !val { return nil } // Get working directory workDir := opts.WorkingDir if workDir == "" { workDir = opts.RootWorkingDir } if workDir == "" { // Fallback to current directory if neither is set workDir = "." } // Check for uncommitted changes gitRunner, err := git.NewGitRunner() if err != nil { return clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } gitRunner = gitRunner.WithWorkDir(workDir) if gitRunner.HasUncommittedChanges(ctx) { l.Warnf("Warning: You have uncommitted changes. The --filter-affected flag may not include all your local modifications.") } defaultBranch := gitRunner.GetDefaultBranch(ctx, l) gitExpr := filter.NewGitExpression(defaultBranch, "HEAD") opts.Filters = append(opts.Filters, filter.NewFilter(gitExpr, gitExpr.String())) return nil }, }, ), flags.NewFlag( &clihelper.BoolFlag{ Name: FilterAllowDestroyFlagName, EnvVars: tgPrefix.EnvVars(FilterAllowDestroyFlagName), Destination: &opts.FilterAllowDestroy, Usage: "Allow destroy runs when using Git-based filters.", }, ), flags.NewFlag( &clihelper.GenericFlag[string]{ Name: FilterFileFlagName, EnvVars: tgPrefix.EnvVars(FilterFileFlagName), Destination: &opts.FiltersFile, Usage: "Path to a file containing filter queries, one per line. Default is .terragrunt-filters.", }, ), flags.NewFlag( &clihelper.BoolFlag{ Name: NoFilterFileFlagName, EnvVars: tgPrefix.EnvVars(NoFilterFileFlagName), Destination: &opts.NoFiltersFile, Usage: "Disable automatic reading of .terragrunt-filters file.", }, ), } } ================================================ FILE: internal/cli/flags/shared/graph.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( GraphFlagName = "graph" ) // NewGraphFlag creates the --graph flag for running commands following the DAG. func NewGraphFlag(opts *options.TerragruntOptions, prefix flags.Prefix) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) return flags.NewFlag(&clihelper.BoolFlag{ Name: GraphFlagName, EnvVars: tgPrefix.EnvVars(GraphFlagName), Destination: &opts.Graph, Usage: "Run the specified OpenTofu/Terraform command following the Directed Acyclic Graph (DAG) of dependencies.", Action: func(_ context.Context, _ *clihelper.Context, _ bool) error { if opts.RunAll { return errors.New(new(AllGraphFlagsError)) } return nil }, }) } ================================================ FILE: internal/cli/flags/shared/iamassumerole.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( IAMAssumeRoleFlagName = "iam-assume-role" IAMAssumeRoleDurationFlagName = "iam-assume-role-duration" IAMAssumeRoleSessionNameFlagName = "iam-assume-role-session-name" IAMAssumeRoleWebIdentityTokenFlagName = "iam-assume-role-web-identity-token" ) // NewIAMAssumeRoleFlags creates flags for IAM assume role configuration. func NewIAMAssumeRoleFlags(opts *options.TerragruntOptions, prefix flags.Prefix, commandName string) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) var terragruntPrefixControl flags.RegisterStrictControlsFunc if commandName != "" { terragruntPrefixControl = flags.StrictControlsByCommand(opts.StrictControls, commandName) } else { terragruntPrefixControl = flags.StrictControlsByGlobalFlags(opts.StrictControls) } return clihelper.Flags{ flags.NewFlag( &clihelper.GenericFlag[string]{ Name: IAMAssumeRoleFlagName, EnvVars: tgPrefix.EnvVars(IAMAssumeRoleFlagName), Destination: &opts.IAMRoleOptions.RoleARN, Usage: "Assume the specified IAM role before executing OpenTofu/Terraform.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("iam-role"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.GenericFlag[int64]{ Name: IAMAssumeRoleDurationFlagName, EnvVars: tgPrefix.EnvVars(IAMAssumeRoleDurationFlagName), Destination: &opts.IAMRoleOptions.AssumeRoleDuration, Usage: "Session duration for IAM Assume Role session.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("iam-assume-role-duration"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.GenericFlag[string]{ Name: IAMAssumeRoleSessionNameFlagName, EnvVars: tgPrefix.EnvVars(IAMAssumeRoleSessionNameFlagName), Destination: &opts.IAMRoleOptions.AssumeRoleSessionName, Usage: "Name for the IAM Assumed Role session.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("iam-assume-role-session-name"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.GenericFlag[string]{ Name: IAMAssumeRoleWebIdentityTokenFlagName, EnvVars: tgPrefix.EnvVars(IAMAssumeRoleWebIdentityTokenFlagName), Destination: &opts.IAMRoleOptions.WebIdentityToken, Usage: "For AssumeRoleWithWebIdentity, the WebIdentity token.", }, flags.WithDeprecatedEnvVars( append( terragruntPrefix.EnvVars("iam-web-identity-token"), terragruntPrefix.EnvVars("iam-assume-role-web-identity-token")..., ), terragruntPrefixControl, ), ), } } ================================================ FILE: internal/cli/flags/shared/inputsdebug.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( InputsDebugFlagName = "inputs-debug" ) // NewInputsDebugFlag creates a flag for enabling inputs debug output. func NewInputsDebugFlag(opts *options.TerragruntOptions, prefix flags.Prefix, commandName string) *flags.Flag { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) var terragruntPrefixControl flags.RegisterStrictControlsFunc if commandName != "" { terragruntPrefixControl = flags.StrictControlsByCommand(opts.StrictControls, commandName) } else { terragruntPrefixControl = flags.StrictControlsByGlobalFlags(opts.StrictControls) } return flags.NewFlag( &clihelper.BoolFlag{ Name: InputsDebugFlagName, EnvVars: tgPrefix.EnvVars(InputsDebugFlagName), Destination: &opts.Debug, Usage: "Write debug.tfvars to working folder to help root-cause issues.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("debug"), terragruntPrefixControl), ) } ================================================ FILE: internal/cli/flags/shared/parallelism.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( ParallelismFlagName = "parallelism" ) // NewParallelismFlag creates a flag for specifying parallelism level. func NewParallelismFlag(opts *options.TerragruntOptions) *flags.Flag { tgPrefix := flags.Prefix{flags.TgPrefix} terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return flags.NewFlag( &clihelper.GenericFlag[int]{ Name: ParallelismFlagName, EnvVars: tgPrefix.EnvVars(ParallelismFlagName), Destination: &opts.Parallelism, Usage: "Parallelism for --all commands.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("parallelism"), terragruntPrefixControl), ) } ================================================ FILE: internal/cli/flags/shared/queue.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( QueueIgnoreErrorsFlagName = "queue-ignore-errors" QueueIgnoreDAGOrderFlagName = "queue-ignore-dag-order" QueueExcludeExternalFlagName = "queue-exclude-external" QueueExcludeDirFlagName = "queue-exclude-dir" QueueExcludesFileFlagName = "queue-excludes-file" QueueIncludeDirFlagName = "queue-include-dir" QueueIncludeExternalFlagName = "queue-include-external" QueueStrictIncludeFlagName = "queue-strict-include" QueueIncludeUnitsReadingFlagName = "queue-include-units-reading" ) // NewQueueFlags creates the flags used for queue control func NewQueueFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return clihelper.Flags{ flags.NewFlag( &clihelper.BoolFlag{ Name: QueueIgnoreErrorsFlagName, EnvVars: tgPrefix.EnvVars(QueueIgnoreErrorsFlagName), Destination: &opts.IgnoreDependencyErrors, Usage: "Continue processing Units even if a dependency fails.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("ignore-dependency-errors"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.BoolFlag{ Name: QueueIgnoreDAGOrderFlagName, EnvVars: tgPrefix.EnvVars(QueueIgnoreDAGOrderFlagName), Destination: &opts.IgnoreDependencyOrder, Usage: "Ignore DAG order for --all commands.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("ignore-dependency-order"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.BoolFlag{ Name: QueueExcludeExternalFlagName, EnvVars: tgPrefix.EnvVars(QueueExcludeExternalFlagName), Usage: "Ignore external dependencies for --all commands.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { return opts.StrictControls.FilterByNames(controls.QueueExcludeExternal).Evaluate(ctx) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("ignore-external-dependencies"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.BoolFlag{ Name: QueueIncludeExternalFlagName, EnvVars: tgPrefix.EnvVars(QueueIncludeExternalFlagName), Usage: "Include external dependencies for --all commands.", Hidden: true, Action: func(_ context.Context, _ *clihelper.Context, value bool) error { if !value { return nil } pathExpr, err := filter.NewPathFilter("./**") if err != nil { return err } graphExpr := filter.NewGraphExpression(pathExpr).WithDependencies() opts.Filters = append(opts.Filters, filter.NewFilter(graphExpr, graphExpr.String())) return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("include-external-dependencies"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.GenericFlag[string]{ Name: QueueExcludesFileFlagName, EnvVars: tgPrefix.EnvVars(QueueExcludesFileFlagName), Destination: &opts.ExcludesFile, Hidden: true, Usage: "Path to a file with a list of directories that need to be excluded when running *-all commands.", }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("excludes-file"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.SliceFlag[string]{ Name: QueueExcludeDirFlagName, EnvVars: tgPrefix.EnvVars(QueueExcludeDirFlagName), Hidden: true, Usage: "Unix-style glob of directories to exclude from the queue of Units to run.", Action: func(_ context.Context, _ *clihelper.Context, value []string) error { if len(value) == 0 { return nil } for _, v := range value { pathExpr, err := filter.NewPathFilter(v) if err != nil { return err } prefixExpr := filter.NewPrefixExpression("!", pathExpr) opts.Filters = append(opts.Filters, filter.NewFilter(prefixExpr, prefixExpr.String())) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("exclude-dir"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.SliceFlag[string]{ Name: QueueIncludeDirFlagName, EnvVars: tgPrefix.EnvVars(QueueIncludeDirFlagName), Hidden: true, Usage: "Unix-style glob of directories to include from the queue of Units to run.", Action: func(_ context.Context, _ *clihelper.Context, value []string) error { if len(value) == 0 { return nil } for _, v := range value { pathExpr, err := filter.NewPathFilter(v) if err != nil { return err } opts.Filters = append(opts.Filters, filter.NewFilter(pathExpr, pathExpr.String())) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("include-dir"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.BoolFlag{ Name: QueueStrictIncludeFlagName, EnvVars: tgPrefix.EnvVars(QueueStrictIncludeFlagName), Usage: "If flag is set, only modules under the directories passed in with '--queue-include-dir' will be included.", Hidden: true, Action: func(ctx context.Context, _ *clihelper.Context, value bool) error { if value { return opts.StrictControls.FilterByNames(controls.QueueStrictInclude).Evaluate(ctx) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("strict-include"), terragruntPrefixControl), ), flags.NewFlag( &clihelper.SliceFlag[string]{ Name: QueueIncludeUnitsReadingFlagName, EnvVars: tgPrefix.EnvVars(QueueIncludeUnitsReadingFlagName), Usage: "If flag is set, 'run --all' will only run the command against units that read the specified file via a Terragrunt HCL function or include.", Hidden: true, Action: func(_ context.Context, _ *clihelper.Context, value []string) error { if len(value) == 0 { return nil } for _, v := range value { attrExpr, err := filter.NewAttributeExpression(filter.AttributeReading, v) if err != nil { return err } opts.Filters = append(opts.Filters, filter.NewFilter(attrExpr, attrExpr.String())) } return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("queue-include-units-reading"), terragruntPrefixControl), ), } } ================================================ FILE: internal/cli/flags/shared/scaffold.go ================================================ package shared import ( "context" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( RootFileNameFlagName = "root-file-name" NoIncludeRootFlagName = "no-include-root" NoShellFlagName = "no-shell" NoHooksFlagName = "no-hooks" ) // NewScaffoldingFlags creates the flags shared between catalog and scaffold commands. func NewScaffoldingFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Flags { tgPrefix := prefix.Prepend(flags.TgPrefix) return clihelper.Flags{ flags.NewFlag(&clihelper.GenericFlag[string]{ Name: RootFileNameFlagName, EnvVars: tgPrefix.EnvVars(RootFileNameFlagName), Destination: &opts.ScaffoldRootFileName, Usage: "Name of the root Terragrunt configuration file, if used.", Action: func(ctx context.Context, _ *clihelper.Context, value string) error { if value == "" { return clihelper.NewExitError("root-file-name flag cannot be empty", clihelper.ExitCodeGeneralError) } if value != opts.TerragruntConfigPath { opts.ScaffoldRootFileName = value return nil } if err := opts.StrictControls.FilterByNames("RootTerragruntHCL").Evaluate(ctx); err != nil { return clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } return nil }, }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoIncludeRootFlagName, EnvVars: tgPrefix.EnvVars(NoIncludeRootFlagName), Destination: &opts.ScaffoldNoIncludeRoot, Usage: "Do not include root unit in scaffolding done by catalog.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoShellFlagName, EnvVars: tgPrefix.EnvVars(NoShellFlagName), Destination: &opts.NoShell, Usage: "Disable shell commands when using boilerplate templates.", }), flags.NewFlag(&clihelper.BoolFlag{ Name: NoHooksFlagName, EnvVars: tgPrefix.EnvVars(NoHooksFlagName), Destination: &opts.NoHooks, Usage: "Disable hooks when using boilerplate templates.", }), } } ================================================ FILE: internal/cli/flags/shared/tfpath.go ================================================ package shared import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/pkg/options" ) const ( TFPathFlagName = "tf-path" ) // NewTFPathFlag creates a flag for specifying the OpenTofu/Terraform binary path. func NewTFPathFlag(opts *options.TerragruntOptions) *flags.Flag { tgPrefix := flags.Prefix{flags.TgPrefix} terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) return flags.NewFlag( &clihelper.GenericFlag[string]{ Name: TFPathFlagName, EnvVars: tgPrefix.EnvVars(TFPathFlagName), Usage: "Path to the OpenTofu/Terraform binary. Default is tofu (on PATH).", Setter: func(value string) error { opts.TFPath = value opts.TFPathExplicitlySet = true return nil }, }, flags.WithDeprecatedEnvVars(terragruntPrefix.EnvVars("tfpath"), terragruntPrefixControl), ) } ================================================ FILE: internal/cli/help.go ================================================ package cli // AppHelpTemplate is the main CLI help template. const AppHelpTemplate = `Usage: {{ if .App.UsageText }}{{ wrap .App.UsageText 3 }}{{ else }}{{ .App.HelpName }} [global options] [options]{{ end }}{{ $description := .App.Usage }}{{ if .App.Description }}{{ $description = .App.Description }}{{ end }}{{ if $description }} {{ wrap $description 3 }}{{ end }}{{ $commands := .App.VisibleCommands }}{{ if $commands }}{{ $cv := offsetCommands $commands 5 }} {{ $categories := $commands.GetCategories.Sort }}{{ range $index, $category := $categories }}{{ $categoryCommands := $commands.FilterByCategory $category }}{{ if $index }} {{ end }} {{ $category.Name }}:{{ range $categoryCommands }} {{ $s := .HelpName }}{{ $s }}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}} {{ wrap .Usage $cv }}{{ end }}{{ end }}{{ end }}{{ if .App.VisibleFlags }} Global Options: {{ range $index, $option := .App.VisibleFlags }}{{ if $index }} {{ end }}{{ wrap $option.String 6 }}{{ end }}{{ end }}{{ if not .App.HideVersion }} Version: {{ .App.Version }}{{ end }}{{ if len .App.Authors }} Author: {{ range .App.Authors }}{{ . }}{{ end }} {{ end }} ` // CommandHelpTemplate is the command CLI help template. const CommandHelpTemplate = `Usage: {{ if .Command.UsageText }}{{ wrap .Command.UsageText 3 }}{{ else }}{{ range $index, $parent := parentCommands . }}{{ $parent.HelpName }} {{ end }}{{ .Command.HelpName }}{{ if .Command.VisibleSubcommands }} {{ end }}{{ if .Command.VisibleFlags }} [options]{{ end }}{{ end }}{{ $description := .Command.Usage }}{{ if .Command.Description }}{{ $description = .Command.Description }}{{ end }}{{ if $description }} {{ wrap $description 3 }}{{ end }}{{ if .Command.Examples }} Examples: {{ $s := join .Command.Examples "\n\n" }}{{ wrap $s 3 }}{{ end }}{{ if .Command.VisibleSubcommands }} Commands:{{ $cv := offsetCommands .Command.VisibleSubcommands 5 }}{{ range .Command.VisibleSubcommands }} {{ $s := .HelpName }}{{ $s }}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}} {{ wrap .Usage $cv }}{{ end }}{{ end }}{{ if .Command.VisibleFlags }} Options: {{ range $index, $option := .Command.VisibleFlags.Sort }}{{ if $index }} {{ end }}{{ wrap $option.String 6 }}{{ end }}{{ end }}{{ if .App.VisibleFlags }} Global Options: {{ range $index, $option := .App.VisibleFlags }}{{ if $index }} {{ end }}{{ wrap $option.String 6 }}{{ end }}{{ end }} ` const AppVersionTemplate = `{{ .App.Name }} version {{ .App.Version }} ` ================================================ FILE: internal/cli/help_test.go ================================================ package cli_test import ( "bytes" "fmt" "runtime" "testing" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCommandHelpTemplate(t *testing.T) { t.Parallel() // Set environment variable format based on OS envVarChar := "$" closeEnvVarChar := "" if runtime.GOOS == "windows" { envVarChar = "%" closeEnvVarChar = "%" } tgPrefix := flags.Prefix{flags.TgPrefix} app := clihelper.NewApp() app.Flags = clihelper.Flags{ &clihelper.GenericFlag[string]{ Name: "working-dir", EnvVars: tgPrefix.EnvVars("working-dir"), Usage: "The path to the directory of Terragrunt configurations. Default is current directory.", }, &clihelper.BoolFlag{ Name: "log-disable", EnvVars: tgPrefix.EnvVars("log-disable"), Usage: "Disable logging.", }, }.Sort() cmd := &clihelper.Command{ Name: "run", Usage: "Run an OpenTofu/Terraform command.", UsageText: "terragrunt run [options] -- ", Description: "Run a command, passing arguments to an orchestrated tofu/terraform binary.\n\nThis is the explicit, and most flexible form of running an IaC command with Terragrunt. Shortcuts can be found in \"terragrunt --help\" for common use-cases.", Examples: []string{ "# Run a plan\nterragrunt run -- plan\n# Shortcut:\n# terragrunt plan", "# Run output with -json flag\nterragrunt run -- output -json\n# Shortcut:\n# terragrunt output -json", "# Run a plan against a Stack of configurations in the current directory\nterragrunt run --all -- plan", }, Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "fmt", Usage: "Recursively find hcl files and rewrite them into a canonical format.", }, &clihelper.Command{ Name: "validate", Usage: "Find all hcl files from the config stack and validate them.", }, }, Flags: clihelper.Flags{ &clihelper.BoolFlag{ Name: "all", Aliases: []string{"a"}, EnvVars: tgPrefix.EnvVars("all"), Usage: `Run the specified OpenTofu/Terraform command on the "Stack" of Units in the current directory.`, }, &clihelper.BoolFlag{ Name: "graph", EnvVars: tgPrefix.EnvVars("graph"), Usage: "Run the specified OpenTofu/Terraform command following the Directed Acyclic Graph (DAG) of dependencies.", }, }, } var out bytes.Buffer app.Writer = &out cliCtx := clihelper.NewAppContext(app, nil).NewCommandContext(cmd, nil) require.Error(t, clihelper.ShowCommandHelp(t.Context(), cliCtx)) expectedOutput := fmt.Sprintf(`Usage: terragrunt run [options] -- Run a command, passing arguments to an orchestrated tofu/terraform binary. This is the explicit, and most flexible form of running an IaC command with Terragrunt. Shortcuts can be found in "terragrunt --help" for common use-cases. Examples: # Run a plan terragrunt run -- plan # Shortcut: # terragrunt plan # Run output with -json flag terragrunt run -- output -json # Shortcut: # terragrunt output -json # Run a plan against a Stack of configurations in the current directory terragrunt run --all -- plan Commands: fmt Recursively find hcl files and rewrite them into a canonical format. validate Find all hcl files from the config stack and validate them. Options: --all, -a Run the specified OpenTofu/Terraform command on the "Stack" of Units in the current directory. [%sTG_ALL%s] --graph Run the specified OpenTofu/Terraform command following the Directed Acyclic Graph (DAG) of dependencies. [%sTG_GRAPH%s] Global Options: --log-disable Disable logging. [%sTG_LOG_DISABLE%s] --working-dir value The path to the directory of Terragrunt configurations. Default is current directory. [%sTG_WORKING_DIR%s] `, envVarChar, closeEnvVarChar, envVarChar, closeEnvVarChar, envVarChar, closeEnvVarChar, envVarChar, closeEnvVarChar) assert.Equal(t, expectedOutput, out.String()) } ================================================ FILE: internal/clihelper/app.go ================================================ // Package clihelper provides the CLI framework for Terragrunt. package clihelper import ( "context" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) // App is a wrapper for `urfave`'s `cli.App` struct. It should be created with the cli.NewApp() function. // The main purpose of this wrapper is to parse commands and flags in the way we need, namely, // if during parsing we find undefined commands or flags, instead of returning an error, we consider them as arguments, // regardless of their position among the others registered commands and flags. // // For example, CLI command: // `terragrunt run --all apply --auto-approve --non-interactive` // The `App` will runs the registered command `run --all`, define the registered flags `--log-level`, // `--non-interactive`, and define args `apply --auto-approve` which can be obtained from the App context, // ctx.Args().Slice() type App struct { // AutocompleteInstaller supports autocompletion via the github.com/posener/complete // library. This library supports bash, zsh and fish. To add support // for other shells, please see that library. AutocompleteInstaller AutocompleteInstaller // FlagErrHandler processes any error encountered while parsing flags. FlagErrHandler FlagErrHandlerFunc // ExitErrHandler processes any error encountered while running an App before // it is returned to the caller. If no function is provided, HandleExitCoder // is used as the default behavior. ExitErrHandler ExitErrHandlerFunc *cli.App // Before is an action to execute before any subcommands are run, but after the context is ready. Before ActionFunc // After is an action to execute after // any subcommands are run, but after the subcommand has finished. After ActionFunc // Complete is the function to call when checking for command completions. Complete CompleteFunc // Action is the action to execute when no subcommands are specified. Action ActionFunc // OsExiter is the function used when the app exits. If not set defaults to os.Exit. OsExiter func(code int) // Author is the author of the app. Author string // CustomAppVersionTemplate is a text template for app version topic. CustomAppVersionTemplate string // AutocompleteInstallFlag is the global flag name for installing the autocompletion handlers for the user's shell. AutocompleteInstallFlag string // AutocompleteUninstallFlag is the global flag name for uninstalling the autocompletion handlers for the user's shell. AutocompleteUninstallFlag string // Commands is a list of commands to execute. Commands Commands // Flags is a list of flags to parse. Flags Flags // Examples is a list of examples of using the App in the help. Examples []string // Autocomplete enables or disables subcommand auto-completion support. Autocomplete bool // DisabledErrorOnUndefinedFlag prevents the application to exit and return an error on any undefined flag. DisabledErrorOnUndefinedFlag bool // DisabledErrorOnMultipleSetFlag prevents the application to exit and return an error if any flag is set multiple times. DisabledErrorOnMultipleSetFlag bool } // NewApp returns app new App instance. func NewApp() *App { cliApp := cli.NewApp() cliApp.ExitErrHandler = func(_ *cli.Context, _ error) {} cliApp.HideHelp = true cliApp.HideHelpCommand = true return &App{ App: cliApp, OsExiter: os.Exit, Autocomplete: true, } } // AddFlags adds new flags. func (app *App) AddFlags(flags ...Flag) { app.Flags = append(app.Flags, flags...) } // AddCommands adds new commands. func (app *App) AddCommands(cmds ...*Command) { app.Commands = append(app.Commands, cmds...) } // Run is the entry point to the cli app. Parses the arguments slice and routes to the proper flag/args combination. func (app *App) Run(arguments []string) error { return app.RunContext(context.Background(), arguments) } // RunContext is like Run except it takes a Context that will be // passed to its commands and sub-commands. Through this, you can // propagate timeouts and cancellation requests func (app *App) RunContext(ctx context.Context, arguments []string) (err error) { // remove empty args filteredArguments := []string{} for _, arg := range arguments { if trimmedArg := strings.TrimSpace(arg); len(trimmedArg) > 0 { filteredArguments = append(filteredArguments, trimmedArg) } } arguments = filteredArguments app.SkipFlagParsing = true app.Authors = []*cli.Author{{Name: app.Author}} app.App.Action = func(parentCtx *cli.Context) error { cmd := app.NewRootCommand() args := Args(parentCtx.Args().Slice()) cliCtx := NewAppContext(app, args) if app.Autocomplete { if err := app.setupAutocomplete(args); err != nil { return app.handleExitCoder(cliCtx, err) } if compLine := os.Getenv(envCompleteLine); compLine != "" { args = strings.Fields(compLine) if args[0] == app.Name { args = args[1:] } cliCtx.shellComplete = true } } return cmd.Run(parentCtx.Context, cliCtx, args) } return app.App.RunContext(ctx, arguments) } // VisibleFlags returns a slice of the Flags used for help. func (app *App) VisibleFlags() Flags { return app.Flags.VisibleFlags() } // VisibleCommands returns a slice of the Commands used for help. func (app *App) VisibleCommands() Commands { if app.Commands == nil { return nil } return app.Commands.Sort().VisibleCommands() } func (app *App) NewRootCommand() *Command { return &Command{ Name: app.Name, Before: app.Before, After: app.After, Action: app.Action, Usage: app.Usage, UsageText: app.UsageText, Description: app.Description, Examples: app.Examples, Flags: app.Flags, Subcommands: app.Commands, Complete: app.Complete, IsRoot: true, DisabledErrorOnUndefinedFlag: app.DisabledErrorOnUndefinedFlag, DisabledErrorOnMultipleSetFlag: app.DisabledErrorOnMultipleSetFlag, } } func (app *App) setupAutocomplete(arguments []string) error { var ( isAutocompleteInstall bool isAutocompleteUninstall bool ) if app.AutocompleteInstallFlag == "" { app.AutocompleteInstallFlag = defaultAutocompleteInstallFlag } if app.AutocompleteUninstallFlag == "" { app.AutocompleteUninstallFlag = defaultAutocompleteUninstallFlag } if app.AutocompleteInstaller == nil { app.AutocompleteInstaller = &autocompleteInstaller{} } for _, arg := range arguments { switch arg { case "-" + app.AutocompleteInstallFlag, "--" + app.AutocompleteInstallFlag: isAutocompleteInstall = true case "-" + app.AutocompleteUninstallFlag, "--" + app.AutocompleteUninstallFlag: isAutocompleteUninstall = true } } // Autocomplete requires the "Name" to be set so that we know what command to setup the autocomplete on. if app.Name == "" { return errors.Errorf("internal error: App.Name must be specified for autocomplete to work") } // If both install and uninstall flags are specified, then error if isAutocompleteInstall && isAutocompleteUninstall { return errors.Errorf("either the autocomplete install or uninstall flag may be specified, but not both") } // If the install flag is specified, perform the install or uninstall and exit if isAutocompleteInstall { err := app.AutocompleteInstaller.Install(app.Name) return NewExitError(err, 0) } if isAutocompleteUninstall { err := app.AutocompleteInstaller.Uninstall(app.Name) return NewExitError(err, 0) } return nil } func (app *App) handleExitCoder(ctx *Context, err error) error { if err == nil || err.Error() == "" { return nil } if app.ExitErrHandler != nil { return app.ExitErrHandler(ctx, err) } return handleExitCoder(ctx, err, app.OsExiter) } ================================================ FILE: internal/clihelper/args.go ================================================ package clihelper import ( "regexp" "slices" "strings" ) const ( tailMinArgsLen = 2 BuiltinCmdSep = "--" ) const ( SingleDashFlag NormalizeActsType = iota DoubleDashFlag ) var ( singleDashRegexp = regexp.MustCompile(`^-([^-]|$)`) doubleDashRegexp = regexp.MustCompile(`^--([^-]|$)`) ) type NormalizeActsType byte // Args provides convenient access to CLI arguments. type Args []string // String implements `fmt.Stringer` interface. func (args Args) String() string { return strings.Join(args, " ") } // Split splits `args` into two slices separated by `sep`. func (args Args) Split(sep string) (Args, Args) { if i := slices.Index(args, sep); i >= 0 { return args[:i], args[i+1:] } return args, nil } func (args Args) WithoutBuiltinCmdSep() Args { flags, nonFlags := args.Split(BuiltinCmdSep) return append(slices.Clone(flags), nonFlags...) } // Get returns the nth argument, or else a blank string func (args Args) Get(n int) string { if len(args) > 0 && len(args) > n { return (args)[n] } return "" } // First returns the first argument or a blank string func (args Args) First() string { return args.Get(0) } // Second returns the second argument or a blank string. func (args Args) Second() string { return args.Get(1) } // Last returns the last argument or a blank string. func (args Args) Last() string { return args.Get(len(args) - 1) } // Tail returns the rest of the arguments (not the first one) // or else an empty string slice. func (args Args) Tail() Args { if args.Len() < tailMinArgsLen { return []string{} } return slices.Clone(args[1:]) } // Remove returns `args` with the `name` element removed. func (args Args) Remove(name string) Args { return slices.DeleteFunc(slices.Clone(args), func(arg string) bool { return arg == name }) } // Len returns the length of the wrapped slice func (args Args) Len() int { return len(args) } // Present checks if there are any arguments present func (args Args) Present() bool { return args.Len() != 0 } // Slice returns a copy of the internal slice func (args Args) Slice() []string { return slices.Clone(args) } // Normalize formats the arguments according to the given actions. // if the given act is: // // `SingleDashFlag` - converts all arguments containing double dashes to single dashes // `DoubleDashFlag` - converts all arguments containing single dashes to double dashes func (args Args) Normalize(acts ...NormalizeActsType) Args { result := make(Args, 0, len(args)) for _, arg := range args { for _, act := range acts { switch act { case SingleDashFlag: if doubleDashRegexp.MatchString(arg) { arg = arg[1:] } case DoubleDashFlag: if singleDashRegexp.MatchString(arg) { arg = "-" + arg } } } result = append(result, arg) } return result } // CommandNameN returns the nth argument from `args` that starts without a dash `-`. func (args Args) CommandNameN(n int) string { var found int for _, arg := range args { if !strings.HasPrefix(arg, "-") { if found == n { return arg } found++ } } return "" } // CommandName returns the first arg that starts without a dash `-`, // otherwise that means the args do not consist any command and an empty string is returned. func (args Args) CommandName() string { return args.CommandNameN(0) } // SubCommandName returns the second arg that starts without a dash `-`, // otherwise that means the args do not consist a subcommand and an empty string is returned. func (args Args) SubCommandName() string { return args.CommandNameN(1) } // Contains returns true if args contains the given `target` arg. func (args Args) Contains(target string) bool { return slices.Contains(args, target) } ================================================ FILE: internal/clihelper/args_test.go ================================================ package clihelper_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" ) var mockArgs = func() clihelper.Args { return clihelper.Args{"one", "-foo", "two", "--bar", "value"} } func TestArgsSlice(t *testing.T) { t.Parallel() actual := mockArgs().Slice() expected := []string(mockArgs()) assert.Equal(t, expected, actual) } func TestArgsTail(t *testing.T) { t.Parallel() actual := mockArgs().Tail() expected := mockArgs()[1:] assert.Equal(t, expected, actual) } func TestArgsFirst(t *testing.T) { t.Parallel() actual := mockArgs().First() expected := mockArgs()[0] assert.Equal(t, expected, actual) } func TestArgsGet(t *testing.T) { t.Parallel() actual := mockArgs().Get(2) expected := "two" assert.Equal(t, expected, actual) } func TestArgsLen(t *testing.T) { t.Parallel() actual := mockArgs().Len() expected := 5 assert.Equal(t, expected, actual) } func TestArgsPresent(t *testing.T) { t.Parallel() actual := mockArgs().Present() expected := true assert.Equal(t, expected, actual) args := clihelper.Args([]string{}) actual = args.Present() expected = false assert.Equal(t, expected, actual) } func TestArgsCommandName(t *testing.T) { t.Parallel() actual := mockArgs().CommandName() expected := "one" assert.Equal(t, expected, actual) args := mockArgs()[1:] actual = args.CommandName() expected = "two" assert.Equal(t, expected, actual) } func TestArgsNormalize(t *testing.T) { t.Parallel() actual := mockArgs().Normalize(clihelper.SingleDashFlag).Slice() expected := []string{"one", "-foo", "two", "-bar", "value"} assert.Equal(t, expected, actual) actual = mockArgs().Normalize(clihelper.DoubleDashFlag).Slice() expected = []string{"one", "--foo", "two", "--bar", "value"} assert.Equal(t, expected, actual) } func TestArgsRemove(t *testing.T) { t.Parallel() testCases := []struct { args clihelper.Args expectedArgs clihelper.Args removeName string expectedResult clihelper.Args }{ { mockArgs(), mockArgs(), "two", clihelper.Args{"one", "-foo", "--bar", "value"}, }, { mockArgs(), mockArgs(), "one", clihelper.Args{"-foo", "two", "--bar", "value"}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := tc.args.Remove(tc.removeName) assert.Equal(t, tc.expectedResult, actual) assert.Equal(t, tc.expectedArgs, tc.args) }) } } ================================================ FILE: internal/clihelper/autocomplete.go ================================================ package clihelper import ( "context" "fmt" "io" "os" "strings" "unicode/utf8" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/posener/complete/cmd/install" ) // defaultAutocompleteInstallFlag and defaultAutocompleteUninstallFlag are the // default values for the autocomplete install and uninstall flags. const ( defaultAutocompleteInstallFlag = "install-autocomplete" defaultAutocompleteUninstallFlag = "uninstall-autocomplete" envCompleteLine = "COMP_LINE" maxDashesInFlag = 2 ) var DefaultComplete = defaultComplete //nolint:gochecknoglobals // AutocompleteInstaller is an interface to be implemented to perform the // autocomplete installation and uninstallation with a CLI. // // This interface is not exported because it only exists for unit tests // to be able to test that the installation is called properly. type AutocompleteInstaller interface { Install(string) error Uninstall(string) error } // autocompleteInstaller uses the install package to do the // install/uninstall. type autocompleteInstaller struct{} func (i *autocompleteInstaller) Install(cmd string) error { if err := install.Install(cmd); err != nil { return errors.New(err) } return nil } func (i *autocompleteInstaller) Uninstall(cmd string) error { if err := install.Uninstall(cmd); err != nil { return errors.New(err) } return nil } // ShowCompletions prints the lists of commands within a given context func ShowCompletions(ctx context.Context, cliCtx *Context) error { if cmd := cliCtx.Command; cmd != nil && cmd.Complete != nil { return cmd.Complete(ctx, cliCtx) } return DefaultComplete(cliCtx) } func defaultComplete(cliCtx *Context) error { arg := cliCtx.Args().Last() if strings.HasPrefix(arg, "-") { if cmd := cliCtx.Command; cmd != nil { return printFlagSuggestions(arg, cmd.Flags, cliCtx.Writer) } return printFlagSuggestions(arg, cliCtx.Flags, cliCtx.Writer) } if cmd := cliCtx.Command; cmd != nil { return printCommandSuggestions(arg, cmd.Subcommands, cliCtx.Writer) } return printCommandSuggestions(arg, cliCtx.Commands, cliCtx.Writer) } func printCommandSuggestions(arg string, commands []*Command, writer io.Writer) error { errs := []error{} for _, command := range commands { if command.Hidden { continue } for _, name := range command.Names() { if name != "" && (arg == "" || strings.HasPrefix(name, arg)) { _, err := fmt.Fprintln(writer, name) errs = append(errs, err) } } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func printFlagSuggestions(arg string, flags []Flag, writer io.Writer) error { cur := strings.TrimLeft(arg, "-") errs := []error{} for _, flag := range flags { for _, name := range flag.Names() { name = strings.TrimSpace(name) // this will get total count utf8 letters in flag name count := min(utf8.RuneCountInString(name), maxDashesInFlag) // if flag name has more than one utf8 letter and last argument in cli has -- prefix then // skip flag completion for short flags example -v or -x if strings.HasPrefix(arg, "--") && count == 1 { continue } // match if last argument matches this flag and it is not repeated if strings.HasPrefix(name, cur) && cur != name && !cliArgContains(name) { flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) _, err := fmt.Fprintln(writer, flagCompletion) errs = append(errs, err) } } } if len(errs) > 0 { return errors.Join(errs...) } return nil } func cliArgContains(flagName string) bool { for name := range strings.SplitSeq(flagName, ",") { name = strings.TrimSpace(name) count := min(utf8.RuneCountInString(name), maxDashesInFlag) flag := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) if slices.Contains(os.Args, flag) { return true } } return false } ================================================ FILE: internal/clihelper/bool_flag.go ================================================ package clihelper import ( "context" libflag "flag" "fmt" "github.com/urfave/cli/v2" ) // BoolFlag implements Flag var _ Flag = new(BoolFlag) type BoolFlag struct { flag // Action is a function that is called when the flag is specified. It is executed only after all command flags have been parsed. Action FlagActionFunc[bool] // Setter represents the function that is called when the flag is specified. // Executed during value parsing, in case of an error the returned error is wrapped with the flag or environment variable name. Setter FlagSetterFunc[bool] // Destination ia a pointer to which the value of the flag or env var is assigned. // It also uses as the default value displayed in the help. Destination *bool // Name is the name of the flag. Name string // DefaultText is the default value of the flag to display in the help, if it is empty, the value is taken from `Destination`. DefaultText string // Usage is a short usage description to display in help. Usage string // Aliases are usually used for the short flag name, like `-h`. Aliases []string // EnvVars are the names of the env variables that are parsed and assigned to `Destination` before the flag value. EnvVars []string // Negative inverts the value of the flag. // If set to true, then the assigned flag value will be inverted. // Example: With `Negative: true`, `--boolean-flag` sets the value to `false`, and `--boolean-flag=false` sets the value to `true`. Negative bool // Hidden hides the flag from the help. Hidden bool } // Apply applies Flag settings to the given flag set. func (flag *BoolFlag) Apply(set *libflag.FlagSet) error { if flag.FlagValue != nil { return ApplyFlag(flag, set) } if flag.Destination == nil { flag.Destination = new(bool) } valueType := newBoolVar(flag.Destination, flag.Negative) value := newGenericValue(valueType, flag.Setter) flag.FlagValue = &flagValue{ value: value, initialTextValue: value.String(), negative: flag.Negative, } return ApplyFlag(flag, set) } // GetHidden returns true if the flag should be hidden from the help. func (flag *BoolFlag) GetHidden() bool { return flag.Hidden } // GetUsage returns the usage string for the flag. func (flag *BoolFlag) GetUsage() string { return flag.Usage } // GetEnvVars implements `cli.Flag` interface. func (flag *BoolFlag) GetEnvVars() []string { return flag.EnvVars } // TakesValue returns true of the flag takes a value, otherwise false. // Implements `cli.DocGenerationFlag.TakesValue` required to generate help. func (flag *BoolFlag) TakesValue() bool { return false } // GetDefaultText returns the flags value as string representation and an empty string if the flag takes no value at all. func (flag *BoolFlag) GetDefaultText() string { if flag.DefaultText == "" && flag.FlagValue != nil { return flag.GetInitialTextValue() } return flag.DefaultText } // String returns a readable representation of this value (for usage defaults). func (flag *BoolFlag) String() string { return cli.FlagStringer(flag) } // Names returns the names of the flag. func (flag *BoolFlag) Names() []string { if flag.Name == "" { return flag.Aliases } return append([]string{flag.Name}, flag.Aliases...) } // RunAction implements ActionableFlag.RunAction func (flag *BoolFlag) RunAction(ctx context.Context, cliCtx *Context) error { dest := flag.Destination if dest == nil { dest = new(bool) } if flag.Action != nil { return flag.Action(ctx, cliCtx, *dest) } return nil } var _ = FlagVariable[bool](new(boolVar)) // -- bool Type type boolVar struct { *genericVar[bool] negative bool } func newBoolVar(dest *bool, negative bool) *boolVar { return &boolVar{ genericVar: &genericVar[bool]{dest: dest}, negative: negative, } } func (val *boolVar) Clone(dest *bool) FlagVariable[bool] { return &boolVar{ genericVar: &genericVar[bool]{dest: dest}, negative: val.negative, } } func (val *boolVar) Set(str string) error { if err := val.genericVar.Set(str); err != nil { return err } if val.negative { *val.dest = !*val.dest } return nil } // String returns a readable representation of this value func (val *boolVar) String() string { if val.dest == nil { return "" } format := "%v" if _, ok := val.Get().(bool); ok { format = "%t" } return fmt.Sprintf(format, *val.dest) } ================================================ FILE: internal/clihelper/bool_flag_test.go ================================================ package clihelper_test import ( libflag "flag" "fmt" "io" "maps" "strconv" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBoolFlagApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string flag clihelper.BoolFlag expectedValue bool }{ { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo"}, envs: map[string]string{"FOO": "false"}, expectedValue: true, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: nil, envs: map[string]string{"FOO": "true"}, expectedValue: true, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: nil, envs: nil, expectedValue: false, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue(false)}, args: []string{"--foo"}, envs: map[string]string{"FOO": "false"}, expectedValue: true, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", Destination: mockDestValue(true)}, args: nil, envs: nil, expectedValue: true, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", Destination: mockDestValue(true), Negative: true}, args: []string{"--foo"}, envs: nil, expectedValue: false, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue(true), Negative: true}, args: nil, envs: map[string]string{"FOO": "true"}, expectedValue: false, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue(false), Negative: true}, args: nil, envs: map[string]string{"FOO": "false"}, expectedValue: true, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "--foo"}, envs: nil, expectedValue: false, expectedErr: errors.New(`invalid boolean flag foo: setting the flag multiple times`), }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: nil, envs: map[string]string{"FOO": ""}, expectedValue: false, expectedErr: nil, }, { flag: clihelper.BoolFlag{Name: "foo", EnvVars: []string{"FOO"}}, args: nil, envs: map[string]string{"FOO": "monkey"}, expectedValue: false, expectedErr: errors.New(`invalid value "monkey" for env var FOO: must be one of: "0", "1", "f", "t", "false", "true"`), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testBoolFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func testBoolFlagApply(t *testing.T, flag *clihelper.BoolFlag, args []string, envs map[string]string, expectedValue bool, expectedErr error) { t.Helper() var ( actualValue bool expectedDefaultValue string ) if flag.Destination == nil { flag.Destination = new(bool) } expectedDefaultValue = strconv.FormatBool(*flag.Destination) flag.LookupEnvFunc = func(key string) []string { if envs == nil { return nil } if val, ok := envs[key]; ok { return []string{val} } return nil } flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError) flagSet.SetOutput(io.Discard) err := flag.Apply(flagSet) if err == nil { err = flagSet.Parse(args) } if expectedErr != nil { require.Error(t, err) require.ErrorContains(t, expectedErr, err.Error()) return } require.NoError(t, err) actualValue = (flag.Value().Get()).(bool) assert.Equal(t, expectedValue, actualValue) if actualValue { assert.Equal(t, strconv.FormatBool(expectedValue), flag.GetValue(), "GetValue()") } maps.DeleteFunc(envs, func(k, v string) bool { return v == "" }) assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()") assert.Equal(t, expectedDefaultValue, flag.GetDefaultText(), "GetDefaultText()") assert.True(t, flag.Value().IsBoolFlag(), "IsBoolFlag()") assert.False(t, flag.TakesValue(), "TakesValue()") } ================================================ FILE: internal/clihelper/category.go ================================================ package clihelper import "sort" // Category represents a command category used to group commands when displaying them. type Category struct { // Name is the name of the category. Name string // Order is a number indicating the order in the category list. Order uint } // String implements `fmt.Stringer` interface. func (category *Category) String() string { return category.Name } // Categories is a slice of `Category`. type Categories []*Category // Len implements `sort.Interface` interface. func (categories Categories) Len() int { return len(categories) } // Less implements `sort.Interface` interface. func (categories Categories) Less(i, j int) bool { if categories[i].Order == categories[j].Order { return categories[i].Name < categories[j].Name } return categories[i].Order < categories[j].Order } // Swap implements `sort.Interface` interface. func (categories Categories) Swap(i, j int) { categories[i], categories[j] = categories[j], categories[i] } // Sort returns `categories` in sorted order. func (categories Categories) Sort() Categories { sort.Sort(categories) return categories } ================================================ FILE: internal/clihelper/command.go ================================================ package clihelper import ( "context" "errors" libflag "flag" "strings" ) type Command struct { // Category is the category the command belongs to. Category *Category // Before is an action to execute before the command is invoked. // If a non-nil error is returned, no further processing is done. Before ActionFunc // CustomHelp is a custom function to display help text. CustomHelp HelpFunc // After is the function to call after the command is invoked. After ActionFunc // Complete is the function to call for shell completion. Complete CompleteFunc // Action is the function to execute when the command is invoked. // Runs after subcommands are finished. Action ActionFunc // Description is a longer explanation of how the command works. Description string // HelpName is the full name of the command for help. // Defaults to the full command name, including parent commands. HelpName string // Name is the command name. Name string // UsageText is custom text to show on the `Usage` section of the help. UsageText string // CustomHelpTemplate is a custom text template for the help topic. CustomHelpTemplate string // Usage is a short description of the usage for the command. Usage string // Flags is a list of flags to parse. Flags Flags // Examples is a list of examples for using the command in help. Examples []string // Subcommands is a list of subcommands. Subcommands Commands // Aliases is a list of aliases for the command. Aliases []string // IsRoot is true if this is a root "special" command. // NOTE: The author of this comment doesn't know what this means. IsRoot bool // SkipRunning disables the parsing command, but it will // still be shown in help. SkipRunning bool // SkipFlagParsing treats all flags as normal arguments. SkipFlagParsing bool // Hidden hides the command from help. Hidden bool // DisabledErrorOnUndefinedFlag prevents the application to exit and return an error on any undefined flag. DisabledErrorOnUndefinedFlag bool // DisabledErrorOnMultipleSetFlag prevents the application to exit and return an error if any flag is set multiple times. DisabledErrorOnMultipleSetFlag bool } // Names returns the names including short names and aliases. func (cmd *Command) Names() []string { return append([]string{cmd.Name}, cmd.Aliases...) } // HasName returns true if Command.Name matches given name func (cmd *Command) HasName(name string) bool { for _, n := range cmd.Names() { if n == name && name != "" { return true } } return false } // Subcommand returns a subcommand that matches the given name. func (cmd *Command) Subcommand(name string) *Command { for _, c := range cmd.Subcommands { if c.HasName(name) { return c } } return nil } // VisibleFlags returns a slice of the Flags, used by `urfave/cli` package to generate help. func (cmd *Command) VisibleFlags() Flags { return cmd.Flags.VisibleFlags() } // VisibleSubcommands returns a slice of the Commands with Hidden=false. // Used by `urfave/cli` package to generate help. func (cmd *Command) VisibleSubcommands() Commands { if cmd.Subcommands == nil { return nil } return cmd.Subcommands.VisibleCommands() } // Run parses the given args for the presence of flags as well as subcommands. // If this is the final command, starts its execution. func (cmd *Command) Run(ctx context.Context, cliCtx *Context, args Args) (err error) { args, err = cmd.parseFlags(cliCtx, args.Slice()) if err != nil { return NewExitError(err, ExitCodeGeneralError) } cliCtx = cliCtx.NewCommandContext(cmd, args) subCmdName := cliCtx.Args().CommandName() subCmdArgs := cliCtx.Args().Remove(subCmdName) subCmd := cmd.Subcommand(subCmdName) if cliCtx.shellComplete { if cmd := cliCtx.Command.Subcommand(args.CommandName()); cmd == nil { return ShowCompletions(ctx, cliCtx) } if subCmd != nil { return subCmd.Run(ctx, cliCtx, subCmdArgs) } } if err := cmd.Flags.RunActions(ctx, cliCtx); err != nil { return cliCtx.handleExitCoder(cliCtx, err) } defer func() { if cmd.After != nil && err == nil { err = cmd.After(ctx, cliCtx) err = cliCtx.handleExitCoder(cliCtx, err) } }() if cmd.Before != nil { if err := cmd.Before(ctx, cliCtx); err != nil { return cliCtx.handleExitCoder(cliCtx, err) } } if subCmd != nil && !subCmd.SkipRunning { return subCmd.Run(ctx, cliCtx, subCmdArgs) } if cmd.Action != nil { if err = cmd.Action(ctx, cliCtx); err != nil { return cliCtx.handleExitCoder(cliCtx, err) } } return nil } func (cmd *Command) parseFlags(ctx *Context, args Args) ([]string, error) { var undefArgs Args errHandler := func(err error) error { if err == nil { return nil } if cmd.DisabledErrorOnMultipleSetFlag && IsMultipleTimesSettingError(err) { return nil } if flagErrHandler := ctx.FlagErrHandler; flagErrHandler != nil { err = flagErrHandler(ctx.NewCommandContext(cmd, args), err) } return err } flagSet, err := cmd.Flags.NewFlagSet(cmd.Name, errHandler) if err != nil { return nil, err } flagSetWithSubcommandScope, err := cmd.Flags.WithSubcommandScope().NewFlagSet(cmd.Name, errHandler) if err != nil { return nil, err } if cmd.SkipFlagParsing { return args, nil } args, builtinCmd := args.Split(BuiltinCmdSep) for i := 0; len(args) > 0; i++ { if i == 0 { args, err = cmd.flagSetParse(ctx, flagSet, args) } else { args, err = cmd.flagSetParse(ctx, flagSetWithSubcommandScope, args) } if len(args) != 0 { undefArgs = append(undefArgs, args[0]) args = args[1:] } if err != nil { if !errors.As(err, new(UndefinedFlagError)) || (cmd.Subcommands.Get(undefArgs.Get(0)) == nil) { if err = errHandler(err); err != nil { return undefArgs, err } } } } if len(builtinCmd) > 0 { undefArgs = append(undefArgs, BuiltinCmdSep) undefArgs = append(undefArgs, builtinCmd...) } return undefArgs, nil } func (cmd *Command) flagSetParse(ctx *Context, flagSet *libflag.FlagSet, args Args) ([]string, error) { var ( undefArgs []string err error ) if len(args) == 0 { return undefArgs, nil } const maxFlagsParse = 1000 // Maximum flags parse for range maxFlagsParse { // check if the error is due to an undefArgs flag var undefArg string if err = flagSet.Parse(args); err == nil { break } if errStr := err.Error(); strings.HasPrefix(errStr, ErrMsgFlagUndefined) { undefArg = strings.Trim(strings.TrimPrefix(errStr, ErrMsgFlagUndefined), " -") err = UndefinedFlagError(undefArg) } else { break } // cut off the args var notFoundMatch bool for i, arg := range args { // `--var=input=from_env` trims to `var` trimmed := strings.SplitN(strings.Trim(arg, "-"), "=", 2)[0] //nolint:mnd if trimmed == undefArg { undefArgs = append(undefArgs, arg) notFoundMatch = true args = args[i+1:] break } } if !cmd.DisabledErrorOnUndefinedFlag && !ctx.shellComplete { break } // This should be an impossible to reach code path, but in case the arg // splitting failed to happen, this will prevent infinite loops if !notFoundMatch { return nil, err } } undefArgs = append(undefArgs, flagSet.Args()...) return undefArgs, err } func (cmd *Command) WrapAction(fn func(ctx context.Context, cliCtx *Context, action ActionFunc) error) *Command { clone := *cmd action := clone.Action clone.Action = func(ctx context.Context, cliCtx *Context) error { return fn(ctx, cliCtx, action) } clone.Subcommands = clone.Subcommands.WrapAction(fn) return &clone } // DisableErrorOnMultipleSetFlag returns cloned commands with disabled the check for multiple values set for the same flag. func (cmd *Command) DisableErrorOnMultipleSetFlag() *Command { newCmd := *cmd newCmd.DisabledErrorOnMultipleSetFlag = true newCmd.Subcommands = newCmd.Subcommands.DisableErrorOnMultipleSetFlag() return &newCmd } ================================================ FILE: internal/clihelper/command_test.go ================================================ package clihelper_test import ( "context" "fmt" "io" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" urfaveCli "github.com/urfave/cli/v2" ) func TestCommandRun(t *testing.T) { t.Parallel() type TestActionFunc func(expectedOrder int, expectedArgs []string) clihelper.ActionFunc type TestCase struct { expectedErr error args []string command clihelper.Command } testCaseFuncs := []func(action TestActionFunc, skip clihelper.ActionFunc) TestCase{ func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "--foo", "cmd-bar", "--bar", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: skip, Action: skip, After: skip, }, expectedErr: errors.New("invalid boolean flag foo: setting the flag multiple times"), } }, func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "cmd-bar", "--bar", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: action(1, nil), Action: skip, After: action(5, nil), Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "cmd-cux", Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "bar"}}, Before: skip, Action: skip, After: skip, }, &clihelper.Command{ Name: "cmd-bar", Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "bar"}}, Before: action(2, nil), Action: action(3, []string{"one", "-two"}), After: action(4, nil), DisabledErrorOnUndefinedFlag: true, }, }, }, } }, func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "cmd-bar", "--bar", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: action(1, nil), Action: skip, After: action(4, nil), Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "cmd-bar", Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "bar"}}, Before: action(2, nil), After: action(3, nil), DisabledErrorOnUndefinedFlag: true, }, }, }, } }, func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "--bar", "cmd-bar", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: action(1, nil), Action: skip, After: action(5, nil), Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "cmd-bar", Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "bar"}}, Before: action(2, nil), Action: action(3, []string{"one", "-two"}), After: action(4, nil), DisabledErrorOnUndefinedFlag: true, }, }, DisabledErrorOnUndefinedFlag: true, }, } }, func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "cmd-bar", "--bar", "value", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: action(1, nil), Action: skip, After: action(5, nil), Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "cmd-bar", Flags: clihelper.Flags{&clihelper.GenericFlag[string]{Name: "bar"}}, Before: action(2, nil), Action: action(3, []string{"one", "-two"}), After: action(4, nil), DisabledErrorOnUndefinedFlag: true, }, }, }, } }, func(action TestActionFunc, skip clihelper.ActionFunc) TestCase { return TestCase{ args: []string{"--foo", "cmd-bar", "--bar", "value", "one", "-two"}, command: clihelper.Command{ Flags: clihelper.Flags{&clihelper.BoolFlag{Name: "foo"}}, Before: action(1, nil), Action: action(2, []string{"cmd-bar", "--bar", "value", "one", "-two"}), After: action(3, nil), Subcommands: clihelper.Commands{ &clihelper.Command{ Name: "cmd-bar", Flags: clihelper.Flags{&clihelper.GenericFlag[string]{Name: "bar"}}, SkipRunning: true, Before: skip, Action: skip, After: skip, }, }, }, } }, } for i, tcFn := range testCaseFuncs { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() var actualOrder = new(int) action := func(expectedOrder int, expectedArgs []string) clihelper.ActionFunc { return func(ctx context.Context, cliCtx *clihelper.Context) error { (*actualOrder)++ assert.Equal(t, expectedOrder, *actualOrder) if expectedArgs != nil { actualArgs := cliCtx.Args().Slice() assert.Equal(t, expectedArgs, actualArgs) } return nil } } skip := func(ctx context.Context, cliCtx *clihelper.Context) error { assert.Fail(t, "this action must be skipped") return nil } tc := tcFn(action, skip) app := &clihelper.App{App: &urfaveCli.App{Writer: io.Discard}} cliCtx := clihelper.NewAppContext(app, tc.args) err := tc.command.Run(t.Context(), cliCtx, tc.args) if tc.expectedErr != nil { require.EqualError(t, err, tc.expectedErr.Error(), tc) } else { require.NoError(t, err, tc) } }) } } func TestCommandHasName(t *testing.T) { t.Parallel() testCases := []struct { hasName string command clihelper.Command expected bool }{ { command: clihelper.Command{Name: "foo"}, hasName: "bar", }, { command: clihelper.Command{Name: "foo", Aliases: []string{"bar"}}, hasName: "bar", expected: true, }, { command: clihelper.Command{Name: "bar"}, hasName: "bar", expected: true, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := tc.command.HasName(tc.hasName) assert.Equal(t, tc.expected, actual, tc) }) } } func TestCommandNames(t *testing.T) { t.Parallel() testCases := []struct { expected []string command clihelper.Command }{ { command: clihelper.Command{Name: "foo"}, expected: []string{"foo"}, }, { command: clihelper.Command{Name: "foo", Aliases: []string{"bar", "baz"}}, expected: []string{"foo", "bar", "baz"}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := tc.command.Names() assert.Equal(t, tc.expected, actual, tc) }) } } func TestCommandSubcommand(t *testing.T) { t.Parallel() testCases := []struct { expected *clihelper.Command searchCmdName string command clihelper.Command }{ { command: clihelper.Command{Name: "foo", Subcommands: clihelper.Commands{&clihelper.Command{Name: "bar"}, &clihelper.Command{Name: "baz"}}}, searchCmdName: "baz", expected: &clihelper.Command{Name: "baz"}, }, { command: clihelper.Command{Name: "foo", Subcommands: clihelper.Commands{&clihelper.Command{Name: "bar"}, &clihelper.Command{Name: "baz"}}}, searchCmdName: "qux", expected: nil, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := tc.command.Subcommand(tc.searchCmdName) assert.Equal(t, tc.expected, actual, tc) }) } } func TestCommandVisibleSubcommand(t *testing.T) { t.Parallel() testCases := []struct { expected clihelper.Commands command clihelper.Command }{ { command: clihelper.Command{Name: "foo", Subcommands: clihelper.Commands{&clihelper.Command{Name: "bar"}, &clihelper.Command{Name: "baz", HelpName: "helpBaz"}}}, expected: clihelper.Commands{{Name: "bar", HelpName: "bar"}, {Name: "baz", HelpName: "helpBaz"}}, }, { command: clihelper.Command{Name: "foo", Subcommands: clihelper.Commands{&clihelper.Command{Name: "bar", Hidden: true}, &clihelper.Command{Name: "baz"}}}, expected: clihelper.Commands{{Name: "baz", HelpName: "baz"}}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := tc.command.VisibleSubcommands() assert.Equal(t, tc.expected, actual, tc) }) } } ================================================ FILE: internal/clihelper/commands.go ================================================ package clihelper import ( "context" "sort" "strings" "slices" ) type Commands []*Command // Get returns a Command by the given name. func (commands Commands) Get(name string) *Command { for _, cmd := range commands { if cmd.HasName(name) { return cmd } } return nil } // Names returns names of the commands. func (commands Commands) Names() []string { var names = make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name } return names } // Add adds a new cmd to the list. func (commands *Commands) Add(cmd *Command) { *commands = append(*commands, cmd) } // FilterByNames returns a list of commands filtered by the given names. func (commands Commands) FilterByNames(names []string) Commands { var filtered Commands for _, cmd := range commands { for _, name := range names { if cmd.HasName(name) { filtered = append(filtered, cmd) } } } return filtered } // FilterByCategory returns a list of commands filtered by the given `categories`. func (commands Commands) FilterByCategory(categories ...*Category) Commands { var filtered Commands for _, cmd := range commands { if category := cmd.Category; category != nil && slices.Contains(categories, category) { filtered = append(filtered, cmd) } } return filtered } // SkipRunning prevents running commands as the final commands, but keep showing them in help. func (commands Commands) SkipRunning() Commands { for _, cmd := range commands { cmd.SkipRunning = true } return commands } // VisibleCommands returns a slice of the Commands with Hidden=false. // Used by `urfave/cli` package to generate help. func (commands Commands) VisibleCommands() Commands { var visible = make(Commands, 0, len(commands)) for _, cmd := range commands { if cmd.Hidden { continue } if cmd.HelpName == "" { names := append([]string{cmd.Name}, cmd.Aliases...) cmd.HelpName = strings.Join(names, ", ") } visible = append(visible, cmd) } return visible } func (commands Commands) Len() int { return len(commands) } func (commands Commands) Less(i, j int) bool { return LexicographicLess(commands[i].Name, commands[j].Name) } func (commands Commands) Swap(i, j int) { commands[i], commands[j] = commands[j], commands[i] } func (commands Commands) WrapAction(fn func(ctx context.Context, cliCtx *Context, action ActionFunc) error) Commands { wrapped := make(Commands, len(commands)) for i := range commands { wrapped[i] = commands[i].WrapAction(fn) } return wrapped } func (commands Commands) Sort() Commands { sort.Sort(commands) return commands } // SetCategory sets the given `category` for the `commands`. func (commands Commands) SetCategory(category *Category) Commands { for _, cmd := range commands { cmd.Category = category } return commands } // GetCategories returns unique categories commands. func (commands Commands) GetCategories() Categories { var categories Categories for _, cmd := range commands { if category := cmd.Category; category != nil && !slices.Contains(categories, category) { categories = append(categories, category) } } return categories } // Merge merges the given `cmds` with `commands` and returns the result. func (commands Commands) Merge(cmds ...*Command) Commands { return append(commands, cmds...) } // DisableErrorOnMultipleSetFlag returns a cloned command with disabled the check for multiple values set for the same flag. func (commands Commands) DisableErrorOnMultipleSetFlag() Commands { var newCommands = make(Commands, len(commands)) for i := range commands { newCommands[i] = commands[i].DisableErrorOnMultipleSetFlag() } return newCommands } ================================================ FILE: internal/clihelper/context.go ================================================ package clihelper // Context can be used to retrieve context-specific args and parsed command-line options. type Context struct { *App Command *Command parent *Context args Args shellComplete bool } func NewAppContext(app *App, args Args) *Context { return &Context{ App: app, args: args, } } func (ctx *Context) NewCommandContext(command *Command, args Args) *Context { return &Context{ App: ctx.App, Command: command, parent: ctx, args: args, shellComplete: ctx.shellComplete, } } func (ctx *Context) Parent() *Context { return ctx.parent } // Args returns the command line arguments associated with the context. func (ctx *Context) Args() Args { return ctx.args } // Flag retrieves a command flag by name. Returns nil if the command is not set // or if the flag doesn't exist. func (ctx *Context) Flag(name string) Flag { if ctx.Command != nil { return ctx.Command.Flags.Get(name) } return nil } ================================================ FILE: internal/clihelper/errors.go ================================================ package clihelper import ( "fmt" "strings" "errors" "github.com/urfave/cli/v2" ) type InvalidCommandNameError string func (cmdName InvalidCommandNameError) Error() string { return fmt.Sprintf("invalid command name %q", string(cmdName)) } type InvalidKeyValueError struct { value string sep string } func NewInvalidKeyValueError(sep, value string) *InvalidKeyValueError { return &InvalidKeyValueError{value, sep} } func (err InvalidKeyValueError) Error() string { return fmt.Sprintf("invalid key-value pair, expected format KEY%sVALUE, got %s.", err.sep, err.value) } type exitError struct { err error exitCode ExitCode } func (ee *exitError) Unwrap() error { return ee.err } func (ee *exitError) Error() string { if ee.err == nil { return "" } return ee.err.Error() } func (ee *exitError) ExitCode() int { return int(ee.exitCode) } // NewExitError calls Exit to create a new ExitCoder. func NewExitError(message any, exitCode ExitCode) ExitCoder { var err error if message != nil { switch e := message.(type) { case error: err = e default: err = fmt.Errorf("%+v", message) } } return &exitError{ err: err, exitCode: exitCode, } } // handleExitCoder handles errors implementing ExitCoder by printing their // message and calling osExiter with the given exit code. // // If the given error instead implements MultiError, each error will be checked // for the ExitCoder interface, and osExiter will be called with the last exit // code found, or exit code 1 if no ExitCoder is found. // // This function is the default error-handling behavior for an App. func handleExitCoder(_ *Context, err error, osExiter func(code int)) error { if err == nil { return nil } var exitErr cli.ExitCoder if ok := errors.As(err, &exitErr); ok { if err.Error() != "" { _, _ = fmt.Fprintln(cli.ErrWriter, err) } osExiter(exitErr.ExitCode()) return nil } return err } // InvalidValueError is used to wrap errors from `strconv` to make the error message more user friendly. type InvalidValueError struct { underlyingError error msg string } func (err InvalidValueError) Error() string { return err.msg } func (err InvalidValueError) Unwrap() error { return err.underlyingError } const ErrMsgFlagUndefined = "flag provided but not defined:" type UndefinedFlagError string func (flag UndefinedFlagError) Error() string { return ErrMsgFlagUndefined + " -" + string(flag) } var ( ErrMultipleTimesSettingFlag = errors.New("setting the flag multiple times") ErrMultipleTimesSettingEnvVar = errors.New("setting the env var multiple times") ) func IsMultipleTimesSettingError(err error) bool { return strings.Contains(err.Error(), ErrMultipleTimesSettingFlag.Error()) || strings.Contains(err.Error(), ErrMultipleTimesSettingEnvVar.Error()) } ================================================ FILE: internal/clihelper/exit_code.go ================================================ package clihelper // Constants for exit codes. const ( ExitCodeSuccess ExitCode = iota ExitCodeGeneralError ) // ExitCode is a number between 0 and 255, which is returned by any Unix command when it returns control to its parent process. type ExitCode byte // ExitCoder is the interface checked by `App` and `Command` for a custom exit code. type ExitCoder interface { error ExitCode() int Unwrap() error } ================================================ FILE: internal/clihelper/flag.go ================================================ package clihelper import ( "context" libflag "flag" "fmt" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) var ( // FlagSplitter uses to separate arguments and env vars with multiple values. FlagSplitter = strings.Split ) // FlagSetterFunc represents function type that is called when the flag is specified. // Unlike `FlagActionFunc` where the function is called after the value has been parsed and assigned to the `Destination` field, // `FlagSetterFunc` is called earlier, during the variable parsing. // if `FlagSetterFunc` returns the error, it will be wrapped with the flag or environment variable name. // Example: // `fmt.Errorf("invalid value \"invalid-value\" for env var TG_ENV_VAR: %w", err)` // Therefore, using `FlagSetterFunc` is preferable to `FlagActionFunc` when you need to indicate in the error from where the value came from. // If the flag has multiple values, `FlagSetterFunc` will be called for each value. type FlagSetterFunc[T any] func(value T) error type MapFlagSetterFunc[K any, V any] func(key K, value V) error // FlagActionFunc represents function type that is called when the flag is specified. // Executed after flag have been parsed and assigned to the `Destination` field. type FlagActionFunc[T any] func(ctx context.Context, cliCtx *Context, value T) error type FlagVariable[T any] interface { libflag.Getter Clone(dest *T) FlagVariable[T] } type FlagValue interface { fmt.Stringer Get() any Set(str string) error Getter(name string) FlagValueGetter GetName() string GetInitialTextValue() string // IsSet returns true if the flag was set either by env var or CLI arg. IsSet() bool // IsArgSet returns true if the flag was set by CLI arg. IsArgSet() bool // IsEnvSet returns true if the flag was set by env var. IsEnvSet() bool // IsBoolFlag returns true if the flag is of type bool. IsBoolFlag() bool // IsNegativeBoolFlag returns true if the boolean flag's value should be inverted. // Example: For a flag with Negative=true, when set to true it returns false, and vice versa. IsNegativeBoolFlag() bool // MultipleSet returns true if the flag allows multiple assignments, such as slice/map. MultipleSet() bool } type Flag interface { // `urfave/cli/v2` uses to generate help cli.DocGenerationFlag // Value returns the `FlagValue` interface for interacting with the flag value. Value() FlagValue // GetHidden returns true if the flag is hidden. GetHidden() bool // RunAction runs the flag action. RunAction(ctx context.Context, cliCtx *Context) error // LookupEnv gets and splits the environment variable depending on the flag type: common, map, slice. LookupEnv(envVar string) []string // AllowedSubcommandScope returns true if the flag is allowed to be specified in subcommands, // and not only after the command it belongs to. AllowedSubcommandScope() bool // Parse parses the given args and environment variables to set the flag value. Parse(args Args) error } type LookupEnvFuncType func(key string) []string type FlagValueGetter interface { libflag.Getter EnvSet(str string) error } type flagValueGetter struct { *flagValue valueName string } func (flag *flagValueGetter) EnvSet(val string) error { var err error if !flag.envHasBeenSet { // may contain a default value or an env var, so it needs to be cleared before the first setting. flag.value.Reset() flag.envHasBeenSet = true } else if !flag.multipleSet { err = errors.New(ErrMultipleTimesSettingEnvVar) } flag.name = flag.valueName if err := flag.value.Set(val); err != nil { return err } return err } func (flag *flagValueGetter) Set(val string) error { var err error if !flag.hasBeenSet { // may contain a default value or an env var, so it needs to be cleared before the first setting. flag.value.Reset() flag.hasBeenSet = true } else if !flag.multipleSet { err = errors.New(ErrMultipleTimesSettingFlag) } flag.name = flag.valueName if err := flag.value.Set(val); err != nil { return err } return err } type Value interface { libflag.Getter Reset() } // flag is a common flag related to parsing flags in cli. type flagValue struct { value Value name string initialTextValue string multipleSet bool hasBeenSet bool envHasBeenSet bool negative bool } func (flag *flagValue) MultipleSet() bool { return flag.multipleSet } // IsBoolFlag implements `cli.FlagValue` interface. func (flag *flagValue) IsBoolFlag() bool { _, ok := flag.value.Get().(bool) return ok } // IsNegativeBoolFlag implements `cli.FlagValue` interface. func (flag *flagValue) IsNegativeBoolFlag() bool { return flag.negative } func (flag *flagValue) Get() any { return flag.value.Get() } func (flag *flagValue) Set(str string) error { return (&flagValueGetter{flagValue: flag}).Set(str) } func (flag *flagValue) String() string { if val := flag.value.Get(); val == nil { return "" } return flag.value.String() } func (flag *flagValue) GetInitialTextValue() string { return flag.initialTextValue } func (flag *flagValue) IsSet() bool { return flag.hasBeenSet || flag.envHasBeenSet } func (flag *flagValue) IsArgSet() bool { return flag.hasBeenSet } func (flag *flagValue) IsEnvSet() bool { return flag.envHasBeenSet } func (flag *flagValue) GetName() string { return flag.name } func (flag *flagValue) Getter(name string) FlagValueGetter { return &flagValueGetter{flagValue: flag, valueName: name} } // flag is a common flag related to parsing flags in cli. type flag struct { FlagValue LookupEnvFunc LookupEnvFuncType } // Parse implements `Flag` interface. func (flag *flag) Parse(args Args) error { return nil } func (flag *flag) LookupEnv(envVar string) []string { if flag.LookupEnvFunc == nil { flag.LookupEnvFunc = func(key string) []string { if val, ok := os.LookupEnv(key); ok { return []string{val} } return nil } } return flag.LookupEnvFunc(envVar) } func (flag *flag) Value() FlagValue { return flag.FlagValue } // TakesValue returns true if the flag needs to be given a value. // Implements `cli.DocGenerationFlag.TakesValue` required to generate help. func (flag *flag) TakesValue() bool { return true } // GetValue returns the flags value as string representation and an empty // string if the flag takes no value at all. // Implements `cli.DocGenerationFlag.GetValue` required to generate help. func (flag *flag) GetValue() string { return flag.String() } // GetCategory returns the category for the flag. // Implements `cli.DocGenerationFlag.GetCategory` required to generate help. func (flag *flag) GetCategory() string { return "" } // AllowedSubcommandScope implements `cli.Flag` interface. func (flag *flag) AllowedSubcommandScope() bool { return true } func ApplyFlag(flag Flag, set *libflag.FlagSet) error { for _, name := range flag.GetEnvVars() { for _, val := range flag.LookupEnv(name) { if val == "" || (flag.Value().IsEnvSet() && !flag.Value().MultipleSet()) { continue } if err := flag.Value().Getter(name).EnvSet(val); err != nil { return errors.Errorf("invalid value %q for env var %s: %w", val, name, err) } } } for _, name := range flag.Names() { if name != "" { set.Var(flag.Value().Getter(name), name, flag.GetUsage()) } } return nil } ================================================ FILE: internal/clihelper/flag_test.go ================================================ package clihelper_test func mockDestValue[T any](val T) *T { return &val } ================================================ FILE: internal/clihelper/flags.go ================================================ package clihelper import ( "context" libflag "flag" "io" "sort" "github.com/gruntwork-io/go-commons/collections" ) type Flags []Flag func (flags Flags) Parse(args Args) error { for _, flag := range flags { if err := flag.Parse(args); err != nil { return err } } return nil } func (flags Flags) NewFlagSet(cmdName string, errHandler func(err error) error) (*libflag.FlagSet, error) { flagSet := libflag.NewFlagSet(cmdName, libflag.ContinueOnError) flagSet.SetOutput(io.Discard) err := flags.Apply(flagSet, errHandler) return flagSet, err } func (flags Flags) Apply(flagSet *libflag.FlagSet, errHandler func(err error) error) error { for _, flag := range flags { if err := flag.Apply(flagSet); err != nil { if err = errHandler(err); err != nil { return err } } } return nil } // Get returns a Flag by the given name. func (flags Flags) Get(name string) Flag { for _, flag := range flags { if collections.ListContainsElement(flag.Names(), name) { return flag } } return nil } // Filter returns a list of flags filtered by the given names. func (flags Flags) Filter(names ...string) Flags { var filtered = make(Flags, 0, len(names)) for _, flag := range flags { for _, name := range names { if collections.ListContainsElement(flag.Names(), name) { filtered = append(filtered, flag) } } } return filtered } // Add adds a new flag to the list. func (flags Flags) Add(newFlags ...Flag) Flags { return append(flags, newFlags...) } // VisibleFlags returns a slice of the Flags. // Used by `urfave/cli` package to generate help. func (flags Flags) VisibleFlags() Flags { var visibleFlags = make(Flags, 0, len(flags)) for _, flag := range flags { if !flag.GetHidden() && len(flag.Names()) > 0 { visibleFlags = append(visibleFlags, flag) } } return visibleFlags } func (flags Flags) Len() int { return len(flags) } func (flags Flags) Less(i, j int) bool { if len(flags[j].Names()) == 0 { return false } else if len(flags[i].Names()) == 0 { return true } return LexicographicLess(flags[i].Names()[0], flags[j].Names()[0]) } func (flags Flags) Swap(i, j int) { flags[i], flags[j] = flags[j], flags[i] } func (flags Flags) RunActions(ctx context.Context, cliCtx *Context) error { for _, flag := range flags { if flag.Value().IsSet() { if err := flag.RunAction(ctx, cliCtx); err != nil { return err } } } return nil } func (flags Flags) Sort() Flags { sort.Sort(flags) return flags } func (flags Flags) WithSubcommandScope() Flags { var filtered Flags for _, flag := range flags { if flag.AllowedSubcommandScope() { filtered = append(filtered, flag) } } return filtered } func (flags Flags) Names() []string { names := make([]string, 0, len(flags)) for _, flag := range flags { names = append(names, flag.Names()...) } return names } ================================================ FILE: internal/clihelper/flags_test.go ================================================ package clihelper_test import ( "context" libflag "flag" "io" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( mockFlagFoo = &clihelper.GenericFlag[string]{Name: "foo"} mockFlagBar = &clihelper.SliceFlag[string]{Name: "bar"} mockFlagBaz = &clihelper.MapFlag[string, string]{Name: "baz"} newMockFlags = func() clihelper.Flags { return clihelper.Flags{ mockFlagFoo, mockFlagBar, mockFlagBaz, } } ) func TestFalgsGet(t *testing.T) { t.Parallel() actual := newMockFlags().Get("bar") expected := clihelper.Flag(mockFlagBar) assert.Equal(t, expected, actual) actual = newMockFlags().Get("break") expected = nil assert.Equal(t, expected, actual) } func TestFalgsAdd(t *testing.T) { t.Parallel() testNewFlag := &clihelper.GenericFlag[string]{Name: "qux"} actual := newMockFlags() actual = actual.Add(testNewFlag) expected := append(newMockFlags(), testNewFlag) assert.Equal(t, expected, actual) } func TestFalgsFilter(t *testing.T) { t.Parallel() actual := newMockFlags().Filter(([]string{"bar", "baz"})...) expected := clihelper.Flags{mockFlagBar, mockFlagBaz} assert.Equal(t, expected, actual) } func TestFalgsRunActions(t *testing.T) { t.Parallel() var actionHasBeenRun bool mockFlags := clihelper.Flags{ &clihelper.SliceFlag[string]{Name: "bar"}, &clihelper.GenericFlag[string]{Name: "foo", Action: func(ctx context.Context, cliCtx *clihelper.Context, val string) error { actionHasBeenRun = true return nil }}, } flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError) flagSet.SetOutput(io.Discard) for _, flag := range mockFlags { err := flag.Apply(flagSet) require.NoError(t, err) err = flag.Value().Set("value") require.NoError(t, err) } assert.False(t, actionHasBeenRun) err := mockFlags.RunActions(t.Context(), nil) require.NoError(t, err) assert.True(t, actionHasBeenRun) } ================================================ FILE: internal/clihelper/funcs.go ================================================ package clihelper import "context" // CompleteFunc is an action to execute when the shell completion flag is set type CompleteFunc func(ctx context.Context, cliCtx *Context) error // ActionFunc is the action to execute when no commands/subcommands are specified. type ActionFunc func(ctx context.Context, cliCtx *Context) error // HelpFunc is the action to execute when help needs to be displayed. // Example: // // func showHelp(ctx context.Context, cliCtx *Context) error { // fmt.Println("Usage: ...") // return nil // } type HelpFunc func(ctx context.Context, cliCtx *Context) error // SplitterFunc is used to parse flags containing multiple values. type SplitterFunc func(s, sep string) []string // ExitErrHandlerFunc is executed if provided in order to handle exitError values // returned by Actions and Before/After functions. type ExitErrHandlerFunc func(ctx *Context, err error) error // FlagErrHandlerFunc is executed if an error occurs while parsing flags. type FlagErrHandlerFunc func(ctx *Context, err error) error ================================================ FILE: internal/clihelper/generic_flag.go ================================================ package clihelper import ( "context" libflag "flag" "fmt" "strconv" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) // GenericFlag implements Flag var _ Flag = new(GenericFlag[string]) type GenericType interface { string | int | int64 | uint } type GenericFlag[T GenericType] struct { flag // Action is a function that is called when the flag is specified. It is executed only after all command flags have been parsed. Action FlagActionFunc[T] // Setter allows to set a value to any type by calling its `func(bool) error` function. Setter FlagSetterFunc[T] // Destination is a pointer to which the value of the flag or env var is assigned. Destination *T // Name is the name of the flag. Name string // DefaultText is the default value of the flag to display in the help, if it is empty, the value is taken from `Destination`. DefaultText string // Usage is a short usage description to display in help. Usage string // Aliases are usually used for the short flag name, like `-h`. Aliases []string // EnvVars are the names of the env variables that are parsed and assigned to `Destination` before the flag value. EnvVars []string // Hidden hides the flag from the help. Hidden bool } // Apply applies Flag settings to the given flag set. func (flag *GenericFlag[T]) Apply(set *libflag.FlagSet) error { if flag.FlagValue != nil { return ApplyFlag(flag, set) } if flag.Destination == nil { flag.Destination = new(T) } valueType := &genericVar[T]{dest: flag.Destination} value := newGenericValue(valueType, flag.Setter) flag.FlagValue = &flagValue{ value: value, initialTextValue: value.String(), } return ApplyFlag(flag, set) } // GetHidden returns true if the flag should be hidden from the help. func (flag *GenericFlag[T]) GetHidden() bool { return flag.Hidden } // GetUsage returns the usage string for the flag. func (flag *GenericFlag[T]) GetUsage() string { return flag.Usage } // GetEnvVars implements `cli.Flag` interface. func (flag *GenericFlag[T]) GetEnvVars() []string { return flag.EnvVars } // GetDefaultText returns the flags value as string representation and an empty string if the flag takes no value at all. func (flag *GenericFlag[T]) GetDefaultText() string { if flag.DefaultText == "" && flag.FlagValue != nil { return flag.GetInitialTextValue() } return flag.DefaultText } // String returns a readable representation of this value (for usage defaults). func (flag *GenericFlag[T]) String() string { return cli.FlagStringer(flag) } // Names returns the names of the flag. func (flag *GenericFlag[T]) Names() []string { if flag.Name == "" { return flag.Aliases } return append([]string{flag.Name}, flag.Aliases...) } // RunAction implements ActionableFlag.RunAction func (flag *GenericFlag[T]) RunAction(ctx context.Context, cliCtx *Context) error { dest := flag.Destination if dest == nil { dest = new(T) } if flag.Action != nil { return flag.Action(ctx, cliCtx, *dest) } return nil } var _ = Value(new(genericValue[string])) // -- generic Value type genericValue[T comparable] struct { setter FlagSetterFunc[T] value FlagVariable[T] } func newGenericValue[T comparable](value FlagVariable[T], setter FlagSetterFunc[T]) *genericValue[T] { return &genericValue[T]{ setter: setter, value: value, } } func (flag *genericValue[T]) Reset() {} func (flag *genericValue[T]) Set(str string) error { if err := flag.value.Set(str); err != nil { return err } if flag.setter != nil { return flag.setter(flag.Get().(T)) } return nil } func (flag *genericValue[T]) Get() any { return flag.value.Get() } func (flag *genericValue[T]) String() string { return flag.value.String() } var _ = FlagVariable[string](new(genericVar[string])) // -- generic Type type genericVar[T comparable] struct { dest *T } func (val *genericVar[T]) Clone(dest *T) FlagVariable[T] { if dest == nil { dest = new(T) } return &genericVar[T]{dest: dest} } func (val *genericVar[T]) Set(str string) error { if val.dest == nil { val.dest = new(T) } switch dest := (any)(val.dest).(type) { case *string: *dest = str case *bool: v, err := strconv.ParseBool(str) if err != nil { return errors.New(InvalidValueError{underlyingError: err, msg: `must be one of: "0", "1", "f", "t", "false", "true"`}) } *dest = v case *int: v, err := strconv.ParseInt(str, 0, strconv.IntSize) if err != nil { return errors.New(InvalidValueError{underlyingError: err, msg: "must be 32-bit integer"}) } *dest = int(v) case *uint: v, err := strconv.ParseUint(str, 10, 64) if err != nil { return errors.New(InvalidValueError{underlyingError: err, msg: "must be 32-bit unsigned integer"}) } *dest = uint(v) case *int64: v, err := strconv.ParseInt(str, 0, 64) if err != nil { return errors.New(InvalidValueError{underlyingError: err, msg: "must be 64-bit integer"}) } *dest = v default: return errors.Errorf("flag type %T is undefined", dest) } return nil } func (val *genericVar[T]) Get() any { if val.dest == nil { return *new(T) } return *val.dest } // String returns a readable representation of this value func (val *genericVar[T]) String() string { if val.dest == nil { return "" } format := "%v" if _, ok := val.Get().(bool); ok { format = "%t" } return fmt.Sprintf(format, *val.dest) } ================================================ FILE: internal/clihelper/generic_flag_test.go ================================================ package clihelper_test import ( "errors" libflag "flag" "fmt" "io" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenericFlagStringApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string expectedValue string args []string flag clihelper.GenericFlag[string] }{ { flag: clihelper.GenericFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "arg-value"}, envs: map[string]string{"FOO": "env-value"}, expectedValue: "arg-value", }, { flag: clihelper.GenericFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "env-value"}, expectedValue: "env-value", }, { flag: clihelper.GenericFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, }, { flag: clihelper.GenericFlag[string]{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue("default-value")}, args: []string{"--foo", "arg-value"}, envs: map[string]string{"FOO": "env-value"}, expectedValue: "arg-value", }, { flag: clihelper.GenericFlag[string]{Name: "foo", Destination: mockDestValue("default-value")}, expectedValue: "default-value", }, { flag: clihelper.GenericFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "arg-value1", "--foo", "arg-value2"}, expectedErr: errors.New(`invalid value "arg-value2" for flag -foo: setting the flag multiple times`), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testGenericFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func TestGenericFlagIntApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string flag clihelper.GenericFlag[int] expectedValue int }{ { flag: clihelper.GenericFlag[int]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "10"}, envs: map[string]string{"FOO": "20"}, expectedValue: 10, }, { flag: clihelper.GenericFlag[int]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{}, envs: map[string]string{"FOO": "20"}, expectedValue: 20, }, { flag: clihelper.GenericFlag[int]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{}, envs: map[string]string{"FOO": "monkey"}, expectedErr: errors.New(`invalid value "monkey" for env var FOO: must be 32-bit integer`), }, { flag: clihelper.GenericFlag[int]{Name: "foo", Destination: mockDestValue(55)}, expectedValue: 55, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testGenericFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func TestGenericFlagInt64Apply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string flag clihelper.GenericFlag[int64] expectedValue int64 }{ { flag: clihelper.GenericFlag[int64]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "10"}, envs: map[string]string{"FOO": "20"}, expectedValue: 10, }, { flag: clihelper.GenericFlag[int64]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{}, envs: map[string]string{"FOO": "20"}, expectedValue: 20, }, { flag: clihelper.GenericFlag[int64]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{}, envs: map[string]string{"FOO": "monkey"}, expectedErr: errors.New(`invalid value "monkey" for env var FOO: must be 64-bit integer`), }, { flag: clihelper.GenericFlag[int64]{Name: "foo", Destination: mockDestValue(int64(55))}, expectedValue: 55, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testGenericFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func testGenericFlagApply[T clihelper.GenericType](t *testing.T, flag *clihelper.GenericFlag[T], args []string, envs map[string]string, expectedValue T, expectedErr error) { t.Helper() var ( actualValue T expectedDefaultValue string ) if flag.Destination == nil { flag.Destination = new(T) } expectedDefaultValue = fmt.Sprintf("%v", *flag.Destination) flag.LookupEnvFunc = func(key string) []string { if envs == nil { return nil } if val, ok := envs[key]; ok { return []string{val} } return nil } flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError) flagSet.SetOutput(io.Discard) err := flag.Apply(flagSet) if err == nil { err = flagSet.Parse(args) } if expectedErr != nil { require.Error(t, err) require.ErrorContains(t, expectedErr, err.Error()) return } require.NoError(t, err) actualValue = (flag.Value().Get()).(T) assert.Equal(t, expectedValue, actualValue) assert.Equal(t, fmt.Sprintf("%v", expectedValue), flag.GetValue(), "GetValue()") assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()") assert.Equal(t, expectedDefaultValue, flag.GetInitialTextValue(), "GetDefaultText()") assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()") assert.True(t, flag.TakesValue(), "TakesValue()") } ================================================ FILE: internal/clihelper/help.go ================================================ package clihelper import ( "context" "slices" "strings" "maps" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) var ( // AppVersionTemplate is the text template for the Default version topic. AppVersionTemplate = "" // AppHelpTemplate is the text template for the Default help topic. AppHelpTemplate = "" // CommandHelpTemplate is the text template for the command help topic. CommandHelpTemplate = "" ) // ShowAppHelp prints App help. func ShowAppHelp(_ context.Context, cliCtx *Context) error { tpl := cliCtx.CustomAppHelpTemplate if tpl == "" { tpl = AppHelpTemplate } if tpl == "" { return errors.Errorf("app help template not defined") } if cliCtx.HelpName == "" { cliCtx.HelpName = cliCtx.Name } cli.HelpPrinterCustom(cliCtx.Writer, tpl, cliCtx, map[string]any{ "parentCommands": parentCommands, "offsetCommands": offsetCommands, }) return NewExitError(nil, ExitCodeSuccess) } // ShowCommandHelp prints command help for the given `cliCtx`. func ShowCommandHelp(ctx context.Context, cliCtx *Context) error { if cliCtx.Command.HelpName == "" { cliCtx.Command.HelpName = cliCtx.Command.Name } if cliCtx.Command.CustomHelp != nil { if err := cliCtx.Command.CustomHelp(ctx, cliCtx); err != nil { return err } return NewExitError(nil, ExitCodeSuccess) } tpl := cliCtx.Command.CustomHelpTemplate if tpl == "" { tpl = CommandHelpTemplate } if tpl == "" { return errors.Errorf("command help template not defined") } HelpPrinterCustom(cliCtx, tpl, nil) return NewExitError(nil, ExitCodeSuccess) } func HelpPrinterCustom(cliCtx *Context, tpl string, customFuncs map[string]any) { var funcs = map[string]any{ "parentCommands": parentCommands, "offsetCommands": offsetCommands, } if customFuncs != nil { maps.Copy(funcs, customFuncs) } cli.HelpPrinterCustom(cliCtx.Writer, tpl, cliCtx, funcs) } func ShowVersion(_ context.Context, cliCtx *Context) error { tpl := cliCtx.CustomAppVersionTemplate if tpl == "" { tpl = AppVersionTemplate } if tpl == "" { return errors.Errorf("app version template not defined") } cli.HelpPrinterCustom(cliCtx.Writer, tpl, cliCtx, nil) return NewExitError(nil, ExitCodeSuccess) } func parentCommands(ctx *Context) Commands { var cmds Commands for parent := ctx.Parent(); parent != nil; parent = parent.Parent() { if cmd := parent.Command; cmd != nil { if cmd.HelpName == "" { cmd.HelpName = cmd.Name } cmds = append(cmds, cmd) } } slices.Reverse(cmds) return cmds } // offsetCommands tries to find the max width of the names column. func offsetCommands(cmds Commands, fixed int) int { var width = 0 for _, cmd := range cmds { s := strings.Join(cmd.Names(), ", ") if len(s) > width { width = len(s) } } return width + fixed } ================================================ FILE: internal/clihelper/map_flag.go ================================================ package clihelper import ( "context" libflag "flag" "os" "strings" "maps" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) // MapFlag implements Flag var _ Flag = new(MapFlag[string, string]) var ( MapFlagEnvVarSep = "," MapFlagKeyValSep = "=" flatPatsCount = 2 ) type MapFlagKeyType interface { GenericType } type MapFlagValueType interface { GenericType | bool } // MapFlag is a key value flag. type MapFlag[K MapFlagKeyType, V MapFlagValueType] struct { flag // Splitter is a function that is called when the flag is specified. It is executed only after all command flags have been parsed. Splitter SplitterFunc // Action is a function that is called when the flag is specified. It is executed only after all command flags have been parsed. Action FlagActionFunc[map[K]V] // Setter represents the function that is called when the flag is specified. Setter MapFlagSetterFunc[K, V] // Destination is a pointer to which the value of the flag or env var is assigned. Destination *map[K]V // DefaultText is the default value of the flag to display in the help, if it is empty, the value is taken from `Destination`. DefaultText string // Usage is a short usage description to display in help. Usage string // Name is the name of the flag. Name string // EnvVarSep is the separator used to split the env var value. EnvVarSep string // KeyValSep is the separator used to split the key and value of the flag. KeyValSep string // Aliases are usually used for the short flag name, like `-h`. Aliases []string // EnvVars are the names of the env variables that are parsed and assigned to `Destination` before the flag value. EnvVars []string // Hidden hides the flag from the help. Hidden bool } // Apply applies Flag settings to the given flag set. func (flag *MapFlag[K, V]) Apply(set *libflag.FlagSet) error { if flag.FlagValue != nil { return ApplyFlag(flag, set) } if flag.Destination == nil { dest := make(map[K]V) flag.Destination = &dest } if flag.Splitter == nil { flag.Splitter = FlagSplitter } if flag.EnvVarSep == "" { flag.EnvVarSep = MapFlagEnvVarSep } if flag.KeyValSep == "" { flag.KeyValSep = MapFlagKeyValSep } if flag.LookupEnvFunc == nil { flag.LookupEnvFunc = func(key string) []string { if val, ok := os.LookupEnv(key); ok { return flag.Splitter(val, flag.EnvVarSep) } return nil } } keyType := FlagVariable[K](new(genericVar[K])) valType := FlagVariable[V](new(genericVar[V])) value := newMapValue(keyType, valType, flag.EnvVarSep, flag.KeyValSep, flag.Splitter, flag.Destination, flag.Setter) flag.FlagValue = &flagValue{ multipleSet: true, value: value, initialTextValue: value.String(), } return ApplyFlag(flag, set) } // GetHidden returns true if the flag should be hidden from the help. func (flag *MapFlag[K, V]) GetHidden() bool { return flag.Hidden } // GetUsage returns the usage string for the flag. func (flag *MapFlag[K, V]) GetUsage() string { return flag.Usage } // GetEnvVars implements `cli.Flag` interface. func (flag *MapFlag[K, V]) GetEnvVars() []string { return flag.EnvVars } // GetDefaultText returns the flags value as string representation and an empty string if the flag takes no value at all. func (flag *MapFlag[K, V]) GetDefaultText() string { if flag.DefaultText == "" && flag.FlagValue != nil { return flag.GetInitialTextValue() } return flag.DefaultText } // String returns a readable representation of this value (for usage defaults). func (flag *MapFlag[K, V]) String() string { return cli.FlagStringer(flag) } // Names returns the names of the flag. func (flag *MapFlag[K, V]) Names() []string { if flag.Name == "" { return flag.Aliases } return append([]string{flag.Name}, flag.Aliases...) } // RunAction implements ActionableFlag.RunAction func (flag *MapFlag[K, V]) RunAction(ctx context.Context, cliCtx *Context) error { if flag.Action != nil { return flag.Action(ctx, cliCtx, *flag.Destination) } return nil } var _ = Value(new(mapValue[string, string])) type mapValue[K, V comparable] struct { keyType FlagVariable[K] valType FlagVariable[V] values *map[K]V setter MapFlagSetterFunc[K, V] splitter SplitterFunc argSep string valSep string } func newMapValue[K, V comparable](keyType FlagVariable[K], valType FlagVariable[V], argSep, valSep string, splitter SplitterFunc, dest *map[K]V, setter MapFlagSetterFunc[K, V]) *mapValue[K, V] { return &mapValue[K, V]{ values: dest, setter: setter, keyType: keyType, valType: valType, argSep: argSep, valSep: valSep, splitter: splitter, } } func (flag *mapValue[K, V]) Reset() { *flag.values = map[K]V{} } func (flag *mapValue[K, V]) Set(str string) error { parts := flag.splitter(str, flag.valSep) if len(parts) != flatPatsCount { return errors.New(NewInvalidKeyValueError(flag.valSep, str)) } key := flag.keyType.Clone(new(K)) if err := key.Set(strings.TrimSpace(parts[0])); err != nil { return err } val := flag.valType.Clone(new(V)) if err := val.Set(strings.TrimSpace(parts[1])); err != nil { return err } (*flag.values)[key.Get().(K)] = val.Get().(V) if flag.setter != nil { return flag.setter(key.Get().(K), val.Get().(V)) } return nil } func (flag *mapValue[K, V]) Get() any { var vals = map[K]V{} maps.Copy(vals, *flag.values) return vals } // String returns a readable representation of this value func (flag *mapValue[K, V]) String() string { if flag.values == nil { return "" } return collections.MapJoin(*flag.values, flag.argSep, flag.valSep) } ================================================ FILE: internal/clihelper/map_flag_test.go ================================================ package clihelper_test import ( libflag "flag" "fmt" "io" "testing" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMapFlagStringStringApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string expectedValue map[string]string args []string flag clihelper.MapFlag[string, string] }{ { flag: clihelper.MapFlag[string, string]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "arg1-key=arg1-value", "--foo", "arg2-key = arg2-value"}, envs: map[string]string{"FOO": "env1-key=env1-value,env2-key=env2-value"}, expectedValue: map[string]string{"arg1-key": "arg1-value", "arg2-key": "arg2-value"}, }, { flag: clihelper.MapFlag[string, string]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "env1-key=env1-value,env2-key = env2-value"}, expectedValue: map[string]string{"env1-key": "env1-value", "env2-key": "env2-value"}, }, { flag: clihelper.MapFlag[string, string]{Name: "foo", EnvVars: []string{"FOO"}}, expectedValue: map[string]string{}, }, { flag: clihelper.MapFlag[string, string]{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue(map[string]string{"default1-key": "default1-value", "default2-key": "default2-value"})}, args: []string{"--foo", "arg1-key=arg1-value", "--foo", "arg2-key=arg2-value"}, envs: map[string]string{"FOO": "env1-key=env1-value,env2-key=env2-value"}, expectedValue: map[string]string{"arg1-key": "arg1-value", "arg2-key": "arg2-value"}, }, { flag: clihelper.MapFlag[string, string]{Name: "foo", Destination: mockDestValue(map[string]string{"default1-key": "default1-value", "default2-key": "default2-value"})}, expectedValue: map[string]string{"default1-key": "default1-value", "default2-key": "default2-value"}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testMapFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func TestMapFlagStringIntApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string expectedValue map[string]int args []string flag clihelper.MapFlag[string, int] }{ { flag: clihelper.MapFlag[string, int]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "arg1-key=10", "--foo", "arg2-key=11"}, envs: map[string]string{"FOO": "env1-key=20,env2-key=21"}, expectedValue: map[string]int{"arg1-key": 10, "arg2-key": 11}, }, { flag: clihelper.MapFlag[string, int]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "env1-key=20,env2-key=21"}, expectedValue: map[string]int{"env1-key": 20, "env2-key": 21}, }, { flag: clihelper.MapFlag[string, int]{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue(map[string]int{"default1-key": 50, "default2-key": 51})}, args: []string{"--foo", "arg1-key=10", "--foo", "arg2-key=11"}, envs: map[string]string{"FOO": "env1-key=20,env2-key=21"}, expectedValue: map[string]int{"arg1-key": 10, "arg2-key": 11}, }, { flag: clihelper.MapFlag[string, int]{Name: "foo", Destination: mockDestValue(map[string]int{"default1-key": 50, "default2-key": 51})}, expectedValue: map[string]int{"default1-key": 50, "default2-key": 51}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testMapFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func testMapFlagApply[K clihelper.MapFlagKeyType, V clihelper.MapFlagValueType](t *testing.T, flag *clihelper.MapFlag[K, V], args []string, envs map[string]string, expectedValue map[K]V, expectedErr error) { t.Helper() var ( actualValue = map[K]V{} destDefined bool expectedDefaultValue = map[K]V{} ) if flag.Destination == nil { destDefined = true flag.Destination = &actualValue } else { expectedDefaultValue = *flag.Destination } flag.LookupEnvFunc = func(key string) []string { if envs == nil { return nil } if val, ok := envs[key]; ok { return flag.Splitter(val, flag.EnvVarSep) } return nil } flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError) flagSet.SetOutput(io.Discard) err := flag.Apply(flagSet) require.NoError(t, err) err = flagSet.Parse(args) if expectedErr != nil { require.Equal(t, expectedErr, err) return } require.NoError(t, err) if !destDefined { actualValue = (flag.Value().Get()).(map[K]V) } assert.Subset(t, expectedValue, actualValue) assert.Equal(t, collections.MapJoin(expectedValue, flag.EnvVarSep, flag.KeyValSep), flag.GetValue(), "GetValue()") assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()") assert.Equal(t, collections.MapJoin(expectedDefaultValue, flag.EnvVarSep, flag.KeyValSep), flag.GetDefaultText(), "GetDefaultText()") assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()") assert.True(t, flag.TakesValue(), "TakesValue()") } ================================================ FILE: internal/clihelper/slice_flag.go ================================================ package clihelper import ( "context" libflag "flag" "os" "strings" "github.com/urfave/cli/v2" ) // SliceFlag implements Flag var _ Flag = new(SliceFlag[string]) var ( SliceFlagEnvVarSep = "," ) type SliceFlagType interface { GenericType } // SliceFlag is a multiple flag. type SliceFlag[T SliceFlagType] struct { flag // Action is a function that is called when the flag is specified. It is executed only after all command flags have been parsed. Action FlagActionFunc[[]T] // Setter represents the function that is called when the flag is specified. Setter FlagSetterFunc[T] // Destination is a pointer to which the value of the flag or env var is assigned. Destination *[]T // Splitter represents the function that is called when the flag is specified. Splitter SplitterFunc // Name is the name of the flag. Name string // DefaultText is the default value of the flag to display in the help, if it is empty, the value is taken from `Destination`. DefaultText string // Usage is a short usage description to display in help. Usage string // EnvVarSep is the separator used to split the env var value. EnvVarSep string // Aliases are usually used for the short flag name, like `-h`. Aliases []string // EnvVars are the names of the env variables that are parsed and assigned to `Destination` before the flag value. EnvVars []string // Hidden hides the flag from the help. Hidden bool } // Apply applies Flag settings to the given flag set. func (flag *SliceFlag[T]) Apply(set *libflag.FlagSet) error { if flag.FlagValue != nil { return ApplyFlag(flag, set) } if flag.Destination == nil { flag.Destination = new([]T) } if flag.Splitter == nil { flag.Splitter = FlagSplitter } if flag.EnvVarSep == "" { flag.EnvVarSep = SliceFlagEnvVarSep } if flag.LookupEnvFunc == nil { flag.LookupEnvFunc = func(key string) []string { if val, ok := os.LookupEnv(key); ok { return flag.Splitter(val, flag.EnvVarSep) } return nil } } valueType := FlagVariable[T](new(genericVar[T])) value := newSliceValue(valueType, flag.EnvVarSep, flag.Destination, flag.Setter) flag.FlagValue = &flagValue{ multipleSet: true, value: value, initialTextValue: value.String(), } return ApplyFlag(flag, set) } // GetHidden returns true if the flag should be hidden from the help. func (flag *SliceFlag[T]) GetHidden() bool { return flag.Hidden } // GetUsage returns the usage string for the flag. func (flag *SliceFlag[T]) GetUsage() string { return flag.Usage } // GetEnvVars implements `cli.Flag` interface. func (flag *SliceFlag[T]) GetEnvVars() []string { return flag.EnvVars } // GetDefaultText returns the flags value as string representation and an empty string if the flag takes no value at all. func (flag *SliceFlag[T]) GetDefaultText() string { if flag.DefaultText == "" && flag.FlagValue != nil { return flag.GetInitialTextValue() } return flag.DefaultText } // String returns a readable representation of this value (for usage defaults). func (flag *SliceFlag[T]) String() string { return cli.FlagStringer(flag) } // Names returns the names of the flag. func (flag *SliceFlag[T]) Names() []string { if flag.Name == "" { return flag.Aliases } return append([]string{flag.Name}, flag.Aliases...) } // RunAction implements ActionableFlag.RunAction func (flag *SliceFlag[T]) RunAction(ctx context.Context, cliCtx *Context) error { if flag.Action != nil { return flag.Action(ctx, cliCtx, *flag.Destination) } return nil } var _ = Value(new(sliceValue[string])) // -- slice Value type sliceValue[T comparable] struct { values *[]T valueType FlagVariable[T] setter FlagSetterFunc[T] valSep string } func newSliceValue[T comparable](valueType FlagVariable[T], valSep string, dest *[]T, setter FlagSetterFunc[T]) *sliceValue[T] { return &sliceValue[T]{ values: dest, valueType: valueType, valSep: valSep, setter: setter, } } func (flag *sliceValue[T]) Reset() { *flag.values = []T{} } func (flag *sliceValue[T]) Set(str string) error { value := flag.valueType.Clone(new(T)) if err := value.Set(str); err != nil { return err } *flag.values = append(*flag.values, value.Get().(T)) if flag.setter != nil { return flag.setter(value.Get().(T)) } return nil } func (flag *sliceValue[T]) Get() any { vals := make([]T, 0, len(*flag.values)) vals = append(vals, *flag.values...) return vals } // String returns a readable representation of this value func (flag *sliceValue[T]) String() string { if flag.values == nil { return "" } var vals = make([]string, 0, len(*flag.values)) for _, val := range *flag.values { vals = append(vals, flag.valueType.Clone(&val).String()) } return strings.Join(vals, flag.valSep) } ================================================ FILE: internal/clihelper/slice_flag_test.go ================================================ package clihelper_test import ( libflag "flag" "fmt" "io" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSliceFlagStringApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string expectedValue []string flag clihelper.SliceFlag[string] }{ { flag: clihelper.SliceFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "arg-value1", "--foo", "arg-value2"}, envs: map[string]string{"FOO": "env-value"}, expectedValue: []string{"arg-value1", "arg-value2"}, }, { flag: clihelper.SliceFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "env-value1,env-value2"}, expectedValue: []string{"env-value1", "env-value2"}, }, { flag: clihelper.SliceFlag[string]{Name: "foo", EnvVars: []string{"FOO"}}, }, { flag: clihelper.SliceFlag[string]{Name: "foo", EnvVars: []string{"FOO"}, Destination: mockDestValue([]string{"default-value1", "default-value2"})}, args: []string{"--foo", "arg-value1", "--foo", "arg-value2"}, envs: map[string]string{"FOO": "env-value1,env-value2"}, expectedValue: []string{"arg-value1", "arg-value2"}, }, { flag: clihelper.SliceFlag[string]{Name: "foo", Destination: mockDestValue([]string{"default-value1", "default-value2"})}, expectedValue: []string{"default-value1", "default-value2"}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testSliceFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func TestSliceFlagIntApply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string expectedValue []int flag clihelper.SliceFlag[int] }{ { flag: clihelper.SliceFlag[int]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "10", "--foo", "11"}, envs: map[string]string{"FOO": "20,21"}, expectedValue: []int{10, 11}, }, { flag: clihelper.SliceFlag[int]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "20,21"}, expectedValue: []int{20, 21}, }, { flag: clihelper.SliceFlag[int]{Name: "foo", Destination: mockDestValue([]int{50, 51})}, expectedValue: []int{50, 51}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testSliceFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func TestSliceFlagInt64Apply(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error envs map[string]string args []string expectedValue []int64 flag clihelper.SliceFlag[int64] }{ { flag: clihelper.SliceFlag[int64]{Name: "foo", EnvVars: []string{"FOO"}}, args: []string{"--foo", "10", "--foo", "11"}, envs: map[string]string{"FOO": "20,21"}, expectedValue: []int64{10, 11}, }, { flag: clihelper.SliceFlag[int64]{Name: "foo", EnvVars: []string{"FOO"}}, envs: map[string]string{"FOO": "20,21"}, expectedValue: []int64{20, 21}, }, { flag: clihelper.SliceFlag[int64]{Name: "foo", Destination: mockDestValue([]int64{50, 51})}, expectedValue: []int64{50, 51}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() testSliceFlagApply(t, &tc.flag, tc.args, tc.envs, tc.expectedValue, tc.expectedErr) }) } } func testSliceFlagApply[T clihelper.SliceFlagType](t *testing.T, flag *clihelper.SliceFlag[T], args []string, envs map[string]string, expectedValue []T, expectedErr error) { t.Helper() var ( actualValue []T destDefined bool expectedDefaultValue []T ) if flag.Destination == nil { destDefined = true flag.Destination = &actualValue } else { expectedDefaultValue = *flag.Destination } flag.LookupEnvFunc = func(key string) []string { if envs == nil { return nil } if val, ok := envs[key]; ok { return flag.Splitter(val, flag.EnvVarSep) } return nil } flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError) flagSet.SetOutput(io.Discard) err := flag.Apply(flagSet) require.NoError(t, err) err = flagSet.Parse(args) if expectedErr != nil { require.Equal(t, expectedErr, err) return } require.NoError(t, err) if !destDefined { actualValue = (flag.Value().Get()).([]T) } assert.Equal(t, expectedValue, actualValue) expectedStringValueFn := func(value []T) string { stringValue := make([]string, 0, len(value)) for _, val := range value { stringValue = append(stringValue, fmt.Sprintf("%v", val)) } return strings.Join(stringValue, flag.EnvVarSep) } assert.Equal(t, expectedStringValueFn(expectedValue), flag.GetValue(), "GetValue()") assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()") assert.Equal(t, expectedStringValueFn(expectedDefaultValue), flag.GetDefaultText(), "GetDefaultText()") assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()") assert.True(t, flag.TakesValue(), "TakesValue()") } ================================================ FILE: internal/clihelper/sort.go ================================================ package clihelper import "unicode" // LexicographicLess compares strings alphabetically considering case. func LexicographicLess(i, j string) bool { iRunes := []rune(i) jRunes := []rune(j) lenShared := min(len(iRunes), len(jRunes)) for index := range lenShared { ir := iRunes[index] jr := jRunes[index] if lir, ljr := unicode.ToLower(ir), unicode.ToLower(jr); lir != ljr { return lir < ljr } if ir != jr { return ir < jr } } return i < j } ================================================ FILE: internal/clihelper/sort_test.go ================================================ package clihelper_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/stretchr/testify/assert" ) func TestLexicographicLess(t *testing.T) { t.Parallel() testCases := []struct { i, j string expected bool }{ {"ab", "cb", true}, {"ab", "ac", true}, {"bf", "bc", false}, {"bb", "bbbb", true}, {"bbbb", "c", true}, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actual := clihelper.LexicographicLess(tc.i, tc.j) assert.Equal(t, tc.expected, actual, tc) }) } } ================================================ FILE: internal/cloner/clone.go ================================================ // Package cloner provides functions to deep clone any Go data. package cloner import "reflect" // Clone returns a deep cloned instance of the given `src` variable. func Clone[T any](src T, opts ...Option) T { //nolint:ireturn conf := &Config{} for _, opt := range opts { opt(conf) } cloner := Cloner[T]{Config: conf} return cloner.Clone(src) } // WithShadowCopyTypes returns an `Option` that forces shadow copies // of the types that are in the given `values`. func WithShadowCopyTypes(values ...any) Option { return func(opt *Config) { for i := range values { opt.shadowCopyTypes = append(opt.shadowCopyTypes, reflect.TypeOf(values[i])) } } } // WithSkippingTypes returns an `Option` that forces skipping copying types // that are in the given `values`. func WithSkippingTypes(values ...any) Option { return func(opt *Config) { for i := range values { opt.skippingTypes = append(opt.skippingTypes, reflect.TypeOf(values[i])) } } } // WithShadowCopyInversePkgPrefixes returns an `Option` that forces shadow copies // of types whose pkg paths do not match the given `prefixes`. func WithShadowCopyInversePkgPrefixes(prefixes ...string) Option { return func(opt *Config) { opt.shadowCopyInversePkgPrefixes = append(opt.shadowCopyInversePkgPrefixes, prefixes...) } } ================================================ FILE: internal/cloner/cloner.go ================================================ package cloner import ( "reflect" "strings" ) const ( fieldTagName = "clone" // fieldTagValueRequired forces to make deep copy of the field even if the field type is disallowed by the option. fieldTagValueRequired = "required" // fieldTagValueShadowCopy specifies that the dst field should be assigned the src field pointer instead of deep copying. fieldTagValueShadowCopy = "shadowcopy" // fieldTagValueSkip specifies that the dst field should have a null value, regardless of the src value. fieldTagValueSkip = "skip" fieldTagValueSkipAlias = "-" ) // Option represents an option to customize deep copied results. type Option func(*Config) type Config struct { shadowCopyTypes []reflect.Type skippingTypes []reflect.Type shadowCopyInversePkgPrefixes []string tagPriorityOnce bool } type Cloner[T any] struct { *Config } func (cloner *Cloner[T]) Clone(src T) T { var dst T val := cloner.cloneValue(reflect.ValueOf(src)) reflect.ValueOf(&dst).Elem().Set(val) return dst } func (cloner *Cloner[T]) getDstValue(src reflect.Value) (reflect.Value, bool) { var ( srcType = src.Type() pkgPath = src.Type().PkgPath() dst = src valid = false ) if cloner.tagPriorityOnce { cloner.tagPriorityOnce = false return dst, valid } if len(cloner.shadowCopyInversePkgPrefixes) != 0 { validInverse := false for _, pkgPrefix := range cloner.shadowCopyInversePkgPrefixes { if pkgPath == "" || strings.HasPrefix(pkgPath, pkgPrefix) { validInverse = true break } } valid = !validInverse } for i := range cloner.skippingTypes { if srcType == cloner.skippingTypes[i] { dst = reflect.Zero(srcType).Elem() valid = true } } for i := range cloner.shadowCopyTypes { if srcType == cloner.shadowCopyTypes[i] { valid = true } } return dst, valid } func (cloner *Cloner[T]) cloneValue(src reflect.Value) reflect.Value { if dst, ok := cloner.getDstValue(src); ok { return dst } if !src.IsValid() { return src } // Look up the corresponding clone function. switch src.Kind() { case reflect.Bool: return cloner.cloneBool(src) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return cloner.cloneInt(src) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return cloner.cloneUint(src) case reflect.Float32, reflect.Float64: return cloner.cloneFloat(src) case reflect.String: return cloner.cloneString(src) case reflect.Slice: return cloner.cloneSlice(src) case reflect.Array: return cloner.cloneArray(src) case reflect.Map: return cloner.cloneMap(src) case reflect.Pointer, reflect.UnsafePointer: return cloner.clonePointer(src) case reflect.Struct: return cloner.cloneStruct(src) case reflect.Invalid, reflect.Uintptr, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func, reflect.Interface: } return src } func (cloner *Cloner[T]) cloneInt(src reflect.Value) reflect.Value { dst := reflect.New(src.Type()).Elem() dst.SetInt(src.Int()) return dst } func (cloner *Cloner[T]) cloneUint(src reflect.Value) reflect.Value { dst := reflect.New(src.Type()).Elem() dst.SetUint(src.Uint()) return dst } func (cloner *Cloner[T]) cloneFloat(src reflect.Value) reflect.Value { dst := reflect.New(src.Type()).Elem() dst.SetFloat(src.Float()) return dst } func (cloner *Cloner[T]) cloneBool(src reflect.Value) reflect.Value { dst := reflect.New(src.Type()).Elem() dst.SetBool(src.Bool()) return dst } func (cloner *Cloner[T]) cloneString(src reflect.Value) reflect.Value { if src, ok := src.Interface().(string); ok { return reflect.ValueOf(strings.Clone(src)) } return src } func (cloner *Cloner[T]) cloneSlice(src reflect.Value) reflect.Value { size := src.Len() dst := reflect.MakeSlice(src.Type(), size, size) for i := range size { if val := cloner.cloneValue(src.Index(i)); val.IsValid() { dst.Index(i).Set(val) } } return dst } func (cloner *Cloner[T]) cloneArray(src reflect.Value) reflect.Value { size := src.Type().Len() dst := reflect.New(reflect.ArrayOf(size, src.Type().Elem())).Elem() for i := range size { if val := cloner.cloneValue(src.Index(i)); val.IsValid() { dst.Index(i).Set(val) } } return dst } func (cloner *Cloner[T]) cloneMap(src reflect.Value) reflect.Value { dst := reflect.MakeMapWithSize(src.Type(), src.Len()) iter := src.MapRange() for iter.Next() { item := cloner.cloneValue(iter.Value()) key := cloner.cloneValue(iter.Key()) dst.SetMapIndex(key, item) } return dst } func (cloner *Cloner[T]) clonePointer(src reflect.Value) reflect.Value { if src.IsNil() { return reflect.Zero(src.Type()).Elem() } dst := reflect.New(src.Type().Elem()) if val := cloner.cloneValue(src.Elem()); val.IsValid() { dst.Elem().Set(val) } return dst } func (cloner *Cloner[T]) cloneStruct(src reflect.Value) reflect.Value { t := src.Type() dst := reflect.New(t) for i := range t.NumField() { srcTypeField := t.Field(i) srcField := src.Field(i) if !srcTypeField.IsExported() { continue } var val reflect.Value switch srcTypeField.Tag.Get(fieldTagName) { case fieldTagValueSkip, fieldTagValueSkipAlias: cloner.tagPriorityOnce = true val = reflect.Zero(srcField.Type()).Elem() case fieldTagValueShadowCopy: cloner.tagPriorityOnce = true val = srcField case fieldTagValueRequired: cloner.tagPriorityOnce = true fallthrough default: val = cloner.cloneValue(srcField) } if val.IsValid() { dst.Elem().Field(i).Set(val) } } return dst.Elem() } ================================================ FILE: internal/codegen/codegen.go ================================================ // Package codegen contains routines for generating terraform code package codegen ================================================ FILE: internal/codegen/errors.go ================================================ package codegen import "fmt" // Custom error types type UnknownGenerateIfExistsVal struct { val string } func (err UnknownGenerateIfExistsVal) Error() string { if err.val != "" { return err.val + " is not a valid value for generate if_exists" } return "Received unknown value for if_exists" } type UnknownGenerateIfDisabledVal struct { val string } func (err UnknownGenerateIfDisabledVal) Error() string { if err.val != "" { return err.val + " is not a valid value for generate if_disabled" } return "Received unknown value for if_disabled" } type GenerateFileExistsError struct { path string } func (err GenerateFileExistsError) Error() string { return fmt.Sprintf("Can not generate terraform file: %s already exists", err.path) } type GenerateFileRemoveError struct { path string } func (err GenerateFileRemoveError) Error() string { return "Can not remove terraform file: " + err.path } ================================================ FILE: internal/codegen/generate.go ================================================ package codegen import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsimple" "github.com/hashicorp/hcl/v2/hclwrite" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( // A comment that is added to the top of the generated file to indicate that this file was generated by Terragrunt. // We use a hardcoded random string at the end to make the string further unique. TerragruntGeneratedSignature = "Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa" // The default prefix to use for comments in the generated file DefaultCommentPrefix = "# " ) // GenerateConfigExists is an enum to represent valid values for if_exists. type GenerateConfigExists int const ( ExistsError GenerateConfigExists = iota ExistsSkip ExistsOverwrite ExistsOverwriteTerragrunt ExistsUnknown ) // GenerateConfigDisabled is an enum to represent valid values for if_disabled. type GenerateConfigDisabled int const ( DisabledSkip GenerateConfigDisabled = iota DisabledRemove DisabledRemoveTerragrunt DisabledUnknown ) const ( ExistsErrorStr = "error" ExistsSkipStr = "skip" ExistsOverwriteStr = "overwrite" ExistsOverwriteTerragruntStr = "overwrite_terragrunt" DisabledSkipStr = "skip" DisabledRemoveStr = "remove" DisabledRemoveTerragruntStr = "remove_terragrunt" assumeRoleConfigKey = "assume_role" assumeRoleWithWebIdentityConfigKey = "assume_role_with_web_identity" encryptionBlockName = "encryption" EncryptionKeyProviderKey = "key_provider" encryptionResourceName = "default" encryptionMethodKey = "method" encryptionDefaultMethod = "aes_gcm" encryptionKeysAttributeName = "keys" encryptionStateBlockName = "state" encryptionPlanBlockName = "plan" ) // GenerateConfig is configuration for generating code type GenerateConfig struct { HclFmt *bool `cty:"hcl_fmt"` Path string `cty:"path"` IfExistsStr string `cty:"if_exists"` IfDisabledStr string `cty:"if_disabled"` CommentPrefix string `cty:"comment_prefix"` Contents string `cty:"contents"` IfExists GenerateConfigExists IfDisabled GenerateConfigDisabled DisableSignature bool `cty:"disable_signature"` Disable bool `cty:"disable"` } // WriteToFile will generate a new file at the given target path with the given contents. If a file already exists at // the target path, the behavior depends on the value of IfExists: // - if ExistsError, return an error. // - if ExistsSkip, do nothing and return // - if ExistsOverwrite, overwrite the existing file func WriteToFile(l log.Logger, basePath string, config *GenerateConfig) error { // Figure out the target path to generate the code in. If relative, merge with basePath. var targetPath string if filepath.IsAbs(config.Path) { targetPath = config.Path } else { targetPath = filepath.Join(basePath, config.Path) } targetFileExists := util.FileExists(targetPath) // If this GenerateConfig is disabled then skip further processing. if config.Disable { l.Debugf("Skipping generating file at %s because it is disabled", config.Path) if targetFileExists { if shouldRemove, err := shouldRemoveWithFileExists(l, targetPath, config.IfDisabled); err != nil { return err } else if shouldRemove { if err := os.Remove(targetPath); err != nil { return errors.New(err) } } } return nil } if targetFileExists { shouldContinue, err := shouldContinueWithFileExists(l, targetPath, config.IfExists) if err != nil || !shouldContinue { return err } } // Add the signature as a prefix to the file, unless it is disabled. prefix := "" if !config.DisableSignature { prefix = fmt.Sprintf("%s%s\n", config.CommentPrefix, TerragruntGeneratedSignature) } fmtGeneratedCode := false if config.HclFmt == nil { var fmtExt = map[string]struct{}{ ".hcl": {}, ".tf": {}, ".tofu": {}, } ext := filepath.Ext(config.Path) if _, ok := fmtExt[ext]; ok { fmtGeneratedCode = true } } else { fmtGeneratedCode = *config.HclFmt } contentsToWrite := fmt.Appendf(nil, "%s%s", prefix, config.Contents) if fmtGeneratedCode { contentsToWrite = hclwrite.Format(contentsToWrite) } const ownerWriteGlobalReadPerms = 0644 if err := os.WriteFile(targetPath, contentsToWrite, ownerWriteGlobalReadPerms); err != nil { return errors.New(err) } l.Debugf("Generated file %s.", targetPath) return nil } // Whether or not file generation should continue if the file path already exists. The answer depends on the // ifExists configuration. func shouldContinueWithFileExists(l log.Logger, path string, ifExists GenerateConfigExists) (bool, error) { // TODO: Make exhaustive switch ifExists { //nolint:exhaustive case ExistsError: return false, errors.New(GenerateFileExistsError{path: path}) case ExistsSkip: // Do nothing since file exists and skip was configured l.Debugf("The file path %s already exists and if_exists for code generation set to \"skip\". Will not regenerate file.", path) return false, nil case ExistsOverwrite: // We will continue to proceed to generate file, but log a message to indicate that we detected the file // exists. l.Debugf("The file path %s already exists and if_exists for code generation set to \"overwrite\". Regenerating file.", path) return true, nil case ExistsOverwriteTerragrunt: // If file was not generated, error out because overwrite_terragrunt if_exists setting only handles if the // existing file was generated by terragrunt. wasGenerated, err := fileWasGeneratedByTerragrunt(path) if err != nil { return false, err } if !wasGenerated { l.Errorf("ERROR: The file path %s already exists and was not generated by terragrunt.", path) return false, errors.New(GenerateFileExistsError{path: path}) } // Since file was generated by terragrunt, continue. l.Debugf("The file path %s already exists, but was a previously generated file by terragrunt. Since if_exists for code generation is set to \"overwrite_terragrunt\", regenerating file.", path) return true, nil default: // This shouldn't happen, but we add this case anyway for defensive coding. return false, errors.New(UnknownGenerateIfExistsVal{""}) } } // shouldRemoveWithFileExists returns true if the already existing file should be removed. func shouldRemoveWithFileExists(l log.Logger, path string, ifDisable GenerateConfigDisabled) (bool, error) { // TODO: Make exhaustive switch ifDisable { //nolint:exhaustive case DisabledSkip: // Do nothing since skip was configured. l.Debugf("The file path %s already exists and if_disabled for code generation set to \"skip\", will not remove file.", path) return false, nil case DisabledRemove: // The file exists and will be removed. l.Debugf("The file path %s already exists and if_disabled for code generation set to \"remove\", removing file.", path) return true, nil case DisabledRemoveTerragrunt: // If file was not generated, error out because remove_terragrunt if_disabled setting only handles if the existing file was generated by terragrunt. wasGenerated, err := fileWasGeneratedByTerragrunt(path) if err != nil { return false, err } if !wasGenerated { l.Errorf("ERROR: The file path %s already exists and was not generated by terragrunt.", path) return false, errors.New(GenerateFileRemoveError{path: path}) } // Since file was generated by terragrunt, removing. l.Debugf("The file path %s already exists, but was a previously generated file by terragrunt. Since if_disabled for code generation is set to \"remove_terragrunt\", removing file.", path) return true, nil default: // This shouldn't happen, but we add this case anyway for defensive coding. return false, errors.New(UnknownGenerateIfDisabledVal{""}) } } // Check if the file was generated by terragrunt by checking if the first line of the file has the signature. Since the // generated string will be prefixed with the configured comment prefix, the check needs to see if the first line ends // with the signature string. func fileWasGeneratedByTerragrunt(path string) (bool, error) { file, err := os.Open(path) if err != nil { return false, errors.New(err) } defer file.Close() reader := bufio.NewReader(file) firstLine, err := reader.ReadString('\n') if err != nil { return false, errors.New(err) } return strings.HasSuffix(strings.TrimSpace(firstLine), TerragruntGeneratedSignature), nil } const ( terraformBlock = "terraform" backendBlock = "backend" ) // RemoteStateConfigToTerraformCode converts the arbitrary map that represents a remote state config into HCL code to configure that remote state. func RemoteStateConfigToTerraformCode(backend string, config map[string]any, encryption map[string]any) ([]byte, error) { f := hclwrite.NewEmptyFile() terraformBlock := f.Body().AppendNewBlock(terraformBlock, nil).Body() backendBlock := terraformBlock.AppendNewBlock(backendBlock, []string{backend}) backendBlockBody := backendBlock.Body() var backendKeys = make([]string, 0, len(config)) for key := range config { backendKeys = append(backendKeys, key) } sort.Strings(backendKeys) for _, key := range backendKeys { // Since we don't have the cty type information for the config and since config can be arbitrary, we cheat by using // json as an intermediate representation. // // handle assume role config key in a different way since it is a single line HCL object if key == assumeRoleConfigKey { assumeRoleValue, isAssumeRole := config[assumeRoleConfigKey].(string) if !isAssumeRole { continue } // Extracting the values requires two steps. // Parsing into a struct first, enabling hclsimple.Decode() to deal with complex types. // Then copying values into the assumeRoleMap for rendering to HCL. assumeRoleMap := make(map[string]any) type assumeRoleConfig struct { RoleArn string `hcl:"role_arn"` Duration string `hcl:"duration,optional"` ExternalID string `hcl:"external_id,optional"` Policy string `hcl:"policy,optional"` PolicyArns []string `hcl:"policy_arns,optional"` SessionName string `hcl:"session_name,optional"` SourceIdentity string `hcl:"source_identity,optional"` Tags map[string]string `hcl:"tags,optional"` TransitiveTagKeys []string `hcl:"transitive_tag_keys,optional"` } var parsedConfig assumeRoleConfig // split single line hcl to default multiline file hclValue := strings.TrimSuffix(assumeRoleValue, "}") hclValue = strings.TrimPrefix(hclValue, "{") hclValue = ReplaceAllCommasOutsideQuotesWithNewLines(hclValue) err := hclsimple.Decode("s3_assume_role.hcl", []byte(hclValue), nil, &parsedConfig) if err != nil { return nil, errors.New(err) } // Copy filled values to the map, could be made shorter but keeping it simple for now if parsedConfig.RoleArn != "" { assumeRoleMap["role_arn"] = parsedConfig.RoleArn } if parsedConfig.Duration != "" { assumeRoleMap["duration"] = parsedConfig.Duration } if parsedConfig.ExternalID != "" { assumeRoleMap["external_id"] = parsedConfig.ExternalID } if parsedConfig.Policy != "" { assumeRoleMap["policy"] = parsedConfig.Policy } if len(parsedConfig.PolicyArns) > 0 { assumeRoleMap["policy_arns"] = parsedConfig.PolicyArns } if parsedConfig.SessionName != "" { assumeRoleMap["session_name"] = parsedConfig.SessionName } if parsedConfig.SourceIdentity != "" { assumeRoleMap["source_identity"] = parsedConfig.SourceIdentity } if len(parsedConfig.Tags) > 0 { assumeRoleMap["tags"] = parsedConfig.Tags } if len(parsedConfig.TransitiveTagKeys) > 0 { assumeRoleMap["transitive_tag_keys"] = parsedConfig.TransitiveTagKeys } // write assume role map as HCL object ctyVal, err := convertValue(assumeRoleMap) if err != nil { return nil, errors.New(err) } backendBlockBody.SetAttributeValue(key, ctyVal.Value) continue } if key == assumeRoleWithWebIdentityConfigKey { assumeRoleWithWebIdentityValue, isAssumeRoleWithWebIdentity := config[assumeRoleWithWebIdentityConfigKey].(string) if !isAssumeRoleWithWebIdentity { continue } // Extracting the values requires two steps. // Parsing into a struct first, enabling hclsimple.Decode() to deal with complex types. // Then copying values into the assumeRoleMap for rendering to HCL. assumeRoleMap := make(map[string]any) type assumeRoleWithWebIdentityConfig struct { RoleArn string `hcl:"role_arn"` Duration string `hcl:"duration,optional"` Policy string `hcl:"policy,optional"` SessionName string `hcl:"session_name,optional"` WebIdentityToken string `hcl:"web_identity_token,optional"` WebIdentityTokenFile string `hcl:"web_identity_token_file,optional"` PolicyArns []string `hcl:"policy_arns,optional"` } var parsedConfig assumeRoleWithWebIdentityConfig // split single line hcl to default multiline file hclValue := strings.TrimSuffix(assumeRoleWithWebIdentityValue, "}") hclValue = strings.TrimPrefix(hclValue, "{") hclValue = ReplaceAllCommasOutsideQuotesWithNewLines(hclValue) err := hclsimple.Decode("s3_assume_role_with_web_identity.hcl", []byte(hclValue), nil, &parsedConfig) if err != nil { return nil, errors.New(err) } if parsedConfig.RoleArn != "" { assumeRoleMap["role_arn"] = parsedConfig.RoleArn } if parsedConfig.Duration != "" { assumeRoleMap["duration"] = parsedConfig.Duration } if parsedConfig.Policy != "" { assumeRoleMap["policy"] = parsedConfig.Policy } if len(parsedConfig.PolicyArns) > 0 { assumeRoleMap["policy_arns"] = parsedConfig.PolicyArns } if parsedConfig.SessionName != "" { assumeRoleMap["session_name"] = parsedConfig.SessionName } if parsedConfig.WebIdentityToken != "" { assumeRoleMap["web_identity_token"] = parsedConfig.WebIdentityToken } if parsedConfig.WebIdentityTokenFile != "" { assumeRoleMap["web_identity_token_file"] = parsedConfig.WebIdentityTokenFile } // write assume role map as HCL object ctyVal, err := convertValue(assumeRoleMap) if err != nil { return nil, errors.New(err) } backendBlockBody.SetAttributeValue(key, ctyVal.Value) continue } ctyVal, err := convertValue(config[key]) if err != nil { return nil, errors.New(err) } backendBlockBody.SetAttributeValue(key, ctyVal.Value) } // encryption can be empty if len(encryption) == 0 { return f.Bytes(), nil } // extract key_provider first to create key_provider block keyProvider, found := encryption[EncryptionKeyProviderKey].(string) if !found { return nil, errors.New(EncryptionKeyProviderKey + " is mandatory but not found in the encryption map") } keyProviderTraversal := hcl.Traversal{ hcl.TraverseRoot{Name: EncryptionKeyProviderKey}, hcl.TraverseAttr{Name: keyProvider}, hcl.TraverseAttr{Name: encryptionResourceName}, } methodTraversal := hcl.Traversal{ hcl.TraverseRoot{Name: encryptionMethodKey}, hcl.TraverseAttr{Name: encryptionDefaultMethod}, hcl.TraverseAttr{Name: encryptionResourceName}, } // encryption block encryptionBlock := terraformBlock.AppendNewBlock(encryptionBlockName, nil) encryptionBlockBody := encryptionBlock.Body() // Append key_provider block keyProviderBlockBody := encryptionBlockBody.AppendNewBlock(EncryptionKeyProviderKey, []string{keyProvider, encryptionResourceName}).Body() // Append method block methodBlock := encryptionBlockBody.AppendNewBlock(encryptionMethodKey, []string{encryptionDefaultMethod, encryptionResourceName}).Body() methodBlock.SetAttributeTraversal(encryptionKeysAttributeName, keyProviderTraversal) // Append state block stateBlock := encryptionBlockBody.AppendNewBlock(encryptionStateBlockName, nil).Body() stateBlock.SetAttributeTraversal(encryptionMethodKey, methodTraversal) // Append plan block planBlock := encryptionBlockBody.AppendNewBlock(encryptionPlanBlockName, nil).Body() planBlock.SetAttributeTraversal(encryptionMethodKey, methodTraversal) var encryptionKeys = make([]string, 0, len(encryption)) for key := range encryption { encryptionKeys = append(encryptionKeys, key) } sort.Strings(encryptionKeys) // Fill key_provider block with ordered attributes for _, key := range encryptionKeys { if key == EncryptionKeyProviderKey { continue } value, ok := encryption[key] if !ok { continue } // Skip basic types with zero values if value == "" || value == 0 { continue } ctyVal, err := convertValue(value) if err != nil { return nil, errors.New(err) } if keyProviderBlockBody != nil { keyProviderBlockBody.SetAttributeValue(key, ctyVal.Value) } } return f.Bytes(), nil } func convertValue(v any) (ctyjson.SimpleJSONValue, error) { jsonBytes, err := json.Marshal(v) if err != nil { return ctyjson.SimpleJSONValue{}, errors.New(err) } var ctyVal ctyjson.SimpleJSONValue if err := ctyVal.UnmarshalJSON(jsonBytes); err != nil { return ctyjson.SimpleJSONValue{}, errors.New(err) } return ctyVal, nil } var ( // Regex Explanation: // ( # Start group 1: Match quoted strings // " # Match the opening quote // [^"\\]* # Match zero or more characters that are NOT a quote or backslash // (?: # Start non-capturing group (for handling escaped quotes) // \\. # Match a backslash followed by ANY character (escaped char) // [^"\\]* # Match zero or more non-quote/non-backslash chars // )* # End non-capturing group, repeat zero or more times // " # Match the closing quote // ) # End group 1 // | # OR // (,) # Start group 2: Match and capture a comma // re = regexp.MustCompile(`("[^"\\]*(?:\\.[^"\\]*)*")|(,)`) ) // ReplaceAllCommasOutsideQuotesWithNewLines replaces all commas outside quotes with new lines. // This is useful for instances where a single line of HCL content might contain a comma, and we don't // want to split the line into multiple lines. func ReplaceAllCommasOutsideQuotesWithNewLines(s string) string { output := re.ReplaceAllStringFunc(s, func(match string) string { // Check if the match starts with a quote. // If it does, it's a quoted string (group 1 matched). Return it unchanged. if strings.HasPrefix(match, `"`) { return match } // Otherwise, it must be the comma (group 2 matched). Replace it with a newline. return "\n" }) return output } // GenerateConfigExistsFromString converts a string representation of if_exists into the enum, returning an error if it // is not set to one of the known values. func GenerateConfigExistsFromString(val string) (GenerateConfigExists, error) { switch val { case ExistsErrorStr: return ExistsError, nil case ExistsSkipStr: return ExistsSkip, nil case ExistsOverwriteStr: return ExistsOverwrite, nil case ExistsOverwriteTerragruntStr: return ExistsOverwriteTerragrunt, nil } return ExistsUnknown, errors.New(UnknownGenerateIfExistsVal{val: val}) } // GenerateConfigDisabledFromString converts a string representation of if_disabled into the enum, returning an error if it is not set to one of the known values. func GenerateConfigDisabledFromString(val string) (GenerateConfigDisabled, error) { switch val { case DisabledSkipStr: return DisabledSkip, nil case DisabledRemoveStr: return DisabledRemove, nil case DisabledRemoveTerragruntStr: return DisabledRemoveTerragrunt, nil } return DisabledUnknown, errors.New(UnknownGenerateIfDisabledVal{val: val}) } ================================================ FILE: internal/codegen/generate_test.go ================================================ package codegen_test import ( "bytes" "fmt" "os" "testing" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRemoteStateConfigToTerraformCode(t *testing.T) { t.Parallel() expectedOrdered := []byte(`terraform { backend "ordered" { a = 1 b = 2 c = 3 } encryption { key_provider "test" "default" { a = 1 b = 2 c = 3 } method "aes_gcm" "default" { keys = key_provider.test.default } state { method = method.aes_gcm.default } plan { method = method.aes_gcm.default } } } `) expectedEmptyConfig := []byte(`terraform { backend "empty" { } encryption { key_provider "test" "default" { } method "aes_gcm" "default" { keys = key_provider.test.default } state { method = method.aes_gcm.default } plan { method = method.aes_gcm.default } } } `) expectedEmptyEncryption := []byte(`terraform { backend "empty" { } } `) expectedS3WithAssumeRole := []byte(`terraform { backend "s3" { assume_role = { duration = "1h30m" external_id = "123456789012" policy = "{}" policy_arns = ["arn:aws:iam::123456789012:policy/MyPolicy"] role_arn = "arn:aws:iam::123456789012:role/MyRole" session_name = "MySession" source_identity = "123456789012" tags = { key = "value" } transitive_tag_keys = ["key"] } bucket = "mybucket" } } `) expectedS3WithAssumeRoleWithWebIdentity := []byte(`terraform { backend "s3" { assume_role_with_web_identity = { duration = "1h30m" policy = "{}" policy_arns = ["arn:aws:iam::123456789012:policy/MyPolicy"] role_arn = "arn:aws:iam::123456789012:role/MyRole" session_name = "MySession" web_identity_token = "123456789012" web_identity_token_file = "/path/to/web_identity_token_file" } bucket = "mybucket" } } `) testCases := []struct { name string backend string config map[string]any encryption map[string]any expected []byte expectErr bool }{ { "remote-state-config-unsorted-keys", "ordered", map[string]any{ "b": 2, "a": 1, "c": 3, }, map[string]any{ "key_provider": "test", "b": 2, "a": 1, "c": 3, }, expectedOrdered, false, }, { "remote-state-config-empty", "empty", map[string]any{}, map[string]any{ "key_provider": "test", }, expectedEmptyConfig, false, }, { "remote-state-encryption-empty", "empty", map[string]any{}, map[string]any{}, expectedEmptyEncryption, false, }, { "remote-state-encryption-missing-key-provider", "empty", map[string]any{}, map[string]any{ "a": 1, }, []byte(""), true, }, { "s3-backend-with-assume-role", "s3", map[string]any{ "bucket": "mybucket", "assume_role": "{role_arn=\"arn:aws:iam::123456789012:role/MyRole\",tags={key=\"value\"}, duration=\"1h30m\", external_id=\"123456789012\", policy=\"{}\", policy_arns=[\"arn:aws:iam::123456789012:policy/MyPolicy\"], session_name=\"MySession\", source_identity=\"123456789012\", transitive_tag_keys=[\"key\"]}", }, map[string]any{}, expectedS3WithAssumeRole, false, }, { "s3-backend-with-assume-role-with-web-identity", "s3", map[string]any{ "bucket": "mybucket", "assume_role_with_web_identity": "{role_arn=\"arn:aws:iam::123456789012:role/MyRole\",duration=\"1h30m\", policy=\"{}\", policy_arns=[\"arn:aws:iam::123456789012:policy/MyPolicy\"], session_name=\"MySession\", web_identity_token=\"123456789012\", web_identity_token_file=\"/path/to/web_identity_token_file\"}", }, map[string]any{}, expectedS3WithAssumeRoleWithWebIdentity, false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() output, err := codegen.RemoteStateConfigToTerraformCode(tc.backend, tc.config, tc.encryption) // validates the first output. if tc.expectErr { require.Error(t, err) } else { require.NoError(t, err) assert.True(t, bytes.Contains(output, []byte(tc.backend))) // Comparing as string produces a nicer diff assert.Equal(t, string(tc.expected), string(output)) } // runs the function a few of times again. All the outputs must be // equal to the first output. for range 20 { actual, _ := codegen.RemoteStateConfigToTerraformCode(tc.backend, tc.config, tc.encryption) assert.Equal(t, output, actual) } }) } } // TestRemoteStateConfigToTerraformCode_BoolValues verifies that native bool // values in the config map produce unquoted true/false in the generated HCL. // This is the expected output when string booleans from HCL ternary type // unification are normalized back to Go bools before reaching codegen. func TestRemoteStateConfigToTerraformCode_BoolValues(t *testing.T) { t.Parallel() expected := []byte(`terraform { backend "s3" { bucket = "my-bucket" encrypt = true key = "terraform.tfstate" region = "us-east-1" use_lockfile = true } } `) config := map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "encrypt": true, "use_lockfile": true, } output, err := codegen.RemoteStateConfigToTerraformCode("s3", config, map[string]any{}) require.NoError(t, err) assert.Equal(t, string(expected), string(output)) } // TestRemoteStateConfigToTerraformCode_StringBoolProducesQuotedValue demonstrates // that string "true"/"false" values produce quoted string literals in generated HCL. // The fix for #5646 normalizes these in S3 GetTFInitArgs before they reach codegen. func TestRemoteStateConfigToTerraformCode_StringBoolProducesQuotedValue(t *testing.T) { t.Parallel() config := map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "use_lockfile": "true", } output, err := codegen.RemoteStateConfigToTerraformCode("s3", config, map[string]any{}) require.NoError(t, err) // String "true" produces a quoted string literal in HCL, which Terraform rejects assert.Contains(t, string(output), `use_lockfile = "true"`) } func TestFmtGeneratedFile(t *testing.T) { t.Parallel() testDir := helpers.TmpDirWOSymlinks(t) bTrue := true bFalse := false testCases := []struct { fmt *bool name string path string contents string expected string ifExists codegen.GenerateConfigExists disabled bool }{ { name: "fmt-simple-hcl-file", fmt: &bTrue, path: fmt.Sprintf("%s/%s", testDir, "fmt_simple.hcl"), contents: "variable \"msg\"{\ntype=string\n default=\"hello\"\n}\n", expected: "variable \"msg\" {\n type = string\n default = \"hello\"\n}\n", ifExists: codegen.ExistsError, }, { name: "fmt-hcl-file-by-default", path: fmt.Sprintf("%s/%s", testDir, "fmt_hcl_file_by_default.hcl"), contents: "variable \"msg\"{\ntype=string\n default=\"hello\"\n}\n", expected: "variable \"msg\" {\n type = string\n default = \"hello\"\n}\n", ifExists: codegen.ExistsError, }, { name: "ignore-hcl-fmt", fmt: &bFalse, path: fmt.Sprintf("%s/%s", testDir, "ignore_hcl_fmt.hcl"), contents: "variable \"msg\"{\ntype=string\n default=\"hello\"\n}\n", expected: "variable \"msg\"{\ntype=string\n default=\"hello\"\n}\n", ifExists: codegen.ExistsError, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() config := codegen.GenerateConfig{ Path: tc.path, IfExists: tc.ifExists, CommentPrefix: "", DisableSignature: true, Contents: tc.contents, Disable: tc.disabled, HclFmt: tc.fmt, } l := logger.CreateLogger() err := codegen.WriteToFile(l, "", &config) require.NoError(t, err) assert.True(t, util.FileExists(tc.path)) fileContent, err := os.ReadFile(tc.path) require.NoError(t, err) assert.Equal(t, tc.expected, string(fileContent)) }) } } func TestGenerateDisabling(t *testing.T) { t.Parallel() testDir := helpers.TmpDirWOSymlinks(t) testCases := []struct { name string path string contents string ifExists codegen.GenerateConfigExists disabled bool }{ { name: "generate-disabled-true", path: fmt.Sprintf("%s/%s", testDir, "disabled_true"), contents: "this file should not be generated", ifExists: codegen.ExistsError, disabled: true, }, { name: "generate-disabled-false", path: fmt.Sprintf("%s/%s", testDir, "disabled_false"), contents: "this file should be generated", ifExists: codegen.ExistsError, disabled: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() config := codegen.GenerateConfig{ Path: tc.path, IfExists: tc.ifExists, CommentPrefix: "", DisableSignature: false, Contents: tc.contents, Disable: tc.disabled, } l := logger.CreateLogger() err := codegen.WriteToFile(l, "", &config) require.NoError(t, err) if tc.disabled { assert.True(t, util.FileNotExists(tc.path)) } else { assert.True(t, util.FileExists(tc.path)) } }) } } func TestReplaceAllCommasOutsideQuotesWithNewLines(t *testing.T) { t.Parallel() testCases := []struct { name string input string expected string }{ { name: "happy-path-basic-replacement", input: `key=value,another=value,third=value`, expected: `key=value another=value third=value`, }, { name: "comma-inside-quotes", input: `key="value,with,commas",another=value`, expected: `key="value,with,commas" another=value`, }, { name: "mixed-quotes-and-commas", input: `key="value,with,commas",simple=value,quoted="hello,world"`, expected: `key="value,with,commas" simple=value quoted="hello,world"`, }, { name: "empty-string", input: ``, expected: ``, }, { name: "no-commas", input: `key=value`, expected: `key=value`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := codegen.ReplaceAllCommasOutsideQuotesWithNewLines(tc.input) assert.Equal(t, tc.expected, actual) }) } } ================================================ FILE: internal/component/component.go ================================================ // Package component provides types for representing discovered Terragrunt components. // // These include units and stacks. // // This package contains only data types and their associated methods, with no discovery logic. // It exists separately from the discovery package to allow other packages (like filter) to // depend on these types without creating circular dependencies. package component import ( "slices" "sort" "sync" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) // Kind is the type of Terragrunt component. type Kind string // Component represents a discovered Terragrunt configuration. // This interface is implemented by Unit and Stack. type Component interface { Kind() Kind Path() string SetPath(string) DisplayPath() string External() bool SetExternal() Reading() []string SetReading(...string) Sources() []string ConfigFile() string DiscoveryContext() *DiscoveryContext SetDiscoveryContext(*DiscoveryContext) Origin() Origin AddDependency(Component) AddDependent(Component) Dependencies() Components Dependents() Components lock() unlock() rLock() rUnlock() ensureDependency(Component) ensureDependent(Component) } // Origin determines the discovery origin of a component. // This is important if there are multiple different reasons that a component might have been discovered. // // e.g. A component might be discovered in a Git worktree due to graph discovery from the results of a Git-based filter. type Origin string const ( OriginUnknown Origin = "unknown" OriginWorktreeDiscovery Origin = "worktree-discovery" OriginGraphDiscovery Origin = "graph-discovery" OriginPathDiscovery Origin = "path-discovery" OriginRelationshipDiscovery Origin = "relationship-discovery" ) // DiscoveryContext is the context in which // a Component was discovered. // // It's useful to know this information, // because it can help us determine how the // Component should be run or enqueued later. type DiscoveryContext struct { WorkingDir string Ref string origin Origin Cmd string Args []string } // Copy returns a copy of the DiscoveryContext. func (dc *DiscoveryContext) Copy() *DiscoveryContext { c := *dc return &c } // CopyWithNewOrigin returns a copy of the DiscoveryContext with the origin set to the given origin. // // Discovered components should never have their origin overridden by subsequent phases of discovery. Only use this // method if you are discovering a new component that was originally discovered by a different discovery phase. // // e.g. A component discovered as a dependency/dependent of a component discovered via Git discovery should be // considered discovered via graph discovery, not Git discovery. func (dc *DiscoveryContext) CopyWithNewOrigin(origin Origin) *DiscoveryContext { c := dc.Copy() c.origin = origin return c } // Origin returns the origin of the DiscoveryContext. func (dc *DiscoveryContext) Origin() Origin { if dc.origin == "" { return OriginUnknown } return dc.origin } // SuggestOrigin suggests an origin for the DiscoveryContext. // // Only actually updates the origin if it is empty. This is to ensure that the origin of a component is always // considered the first origin discovered for that component, and that it can't be overridden by subsequent phases // of discovery that might re-discover the same component. func (dc *DiscoveryContext) SuggestOrigin(origin Origin) { if dc.origin == "" { dc.origin = origin } } // Components is a list of discovered Terragrunt components. type Components []Component // Sort sorts the Components by path. func (c Components) Sort() Components { sort.Slice(c, func(i, j int) bool { return c[i].Path() < c[j].Path() }) return c } // Filter filters the Components by config type. func (c Components) Filter(kind Kind) Components { if len(c) == 0 { return c } filtered := make(Components, 0, len(c)) for _, component := range c { if component.Kind() == kind { filtered = append(filtered, component) } } return filtered } // FilterByPath filters the Components by path. func (c Components) FilterByPath(path string) Components { filtered := make(Components, 0, 1) for _, component := range c { if component.Path() == path { filtered = append(filtered, component) } } return filtered } // RemoveByPath removes the Component with the given path from the Components. func (c Components) RemoveByPath(path string) Components { if len(c) == 0 { return c } filtered := make(Components, 0, len(c)-1) for _, component := range c { if component.Path() != path { filtered = append(filtered, component) } } return filtered } // Paths returns the paths of the Components. func (c Components) Paths() []string { paths := make([]string, 0, len(c)) for _, component := range c { // Skip units explicitly marked as excluded. if unit, ok := component.(*Unit); ok && unit.Excluded() { continue } paths = append(paths, component.Path()) } return paths } // CycleCheck checks for cycles in the dependency graph. // If a cycle is detected, it returns the first Component that is part of the cycle, and an error. // If no cycle is detected, it returns nil and nil. func (c Components) CycleCheck() (Component, error) { visited := make(map[string]bool) inPath := make(map[string]bool) var checkCycle func(component Component) error checkCycle = func(component Component) error { if inPath[component.Path()] { return errors.New("cycle detected in dependency graph at path: " + component.Path()) } if visited[component.Path()] { return nil } visited[component.Path()] = true inPath[component.Path()] = true for _, dep := range component.Dependencies() { if err := checkCycle(dep); err != nil { return err } } inPath[component.Path()] = false return nil } for _, component := range c { if !visited[component.Path()] { if err := checkCycle(component); err != nil { return component, err } } } return nil, nil } // ThreadSafeComponents provides thread-safe access to a Components slice. // It uses an RWMutex to allow concurrent reads and serialized writes. // Resolved paths are cached to avoid repeated filepath.EvalSymlinks syscalls // and ensure consistent symlink-aware comparisons across all methods. type ThreadSafeComponents struct { resolvedPaths map[string]string components Components mu sync.RWMutex } // NewThreadSafeComponents creates a new ThreadSafeComponents instance with the given components. func NewThreadSafeComponents(components Components) *ThreadSafeComponents { tsc := &ThreadSafeComponents{ components: components, resolvedPaths: make(map[string]string, len(components)), } // Pre-populate resolved paths cache for initial components for _, c := range components { tsc.resolvedPaths[c.Path()] = util.ResolvePath(c.Path()) } return tsc } // resolvedPathFor returns the cached resolved path for a component path if present, // otherwise resolves the path on the fly without mutating the cache. // Caller must hold at least a read lock. func (tsc *ThreadSafeComponents) resolvedPathFor(path string) string { if resolved, ok := tsc.resolvedPaths[path]; ok { return resolved } return util.ResolvePath(path) } // EnsureComponent adds a component to the components list if it's not already present. // This method is TOCTOU-safe (Time-Of-Check-Time-Of-Use) by using a double-check pattern. // Path comparison uses resolved symlink paths for consistency. // // It returns the component if it was added, and a boolean indicating if it was added. func (tsc *ThreadSafeComponents) EnsureComponent(c Component) (Component, bool) { found, ok := tsc.findComponent(c) if !ok { return tsc.addComponent(c) } return found, false } // findComponent checks if a component is in the components slice using resolved paths. // If it is, it returns the component and true. // If it is not, it returns nil and false. func (tsc *ThreadSafeComponents) findComponent(c Component) (Component, bool) { tsc.mu.RLock() defer tsc.mu.RUnlock() searchResolved := util.ResolvePath(c.Path()) idx := slices.IndexFunc(tsc.components, func(cc Component) bool { return tsc.resolvedPathFor(cc.Path()) == searchResolved }) if idx == -1 { return nil, false } return tsc.components[idx], true } // addComponent adds a component to the components list, acquiring a write lock. // Uses a double-check pattern to avoid TOCTOU race conditions. // Caches the resolved path for the new component. func (tsc *ThreadSafeComponents) addComponent(c Component) (Component, bool) { tsc.mu.Lock() defer tsc.mu.Unlock() searchResolved := util.ResolvePath(c.Path()) // Do one last check to see if the component is already in the components list // to avoid a TOCTOU race condition. Uses resolved paths for comparison. idx := slices.IndexFunc(tsc.components, func(cc Component) bool { return tsc.resolvedPathFor(cc.Path()) == searchResolved }) if idx != -1 { return tsc.components[idx], false } // Cache resolved path and add component tsc.resolvedPaths[c.Path()] = searchResolved tsc.components = append(tsc.components, c) return c, true } // FindByPath searches for a component by its path and returns it if found, otherwise returns nil. // Paths are resolved to handle symlinks consistently across platforms (e.g., macOS /var -> /private/var). // Uses cached resolved paths to avoid repeated syscalls. func (tsc *ThreadSafeComponents) FindByPath(path string) Component { tsc.mu.RLock() defer tsc.mu.RUnlock() resolvedSearchPath := util.ResolvePath(path) for _, c := range tsc.components { if tsc.resolvedPathFor(c.Path()) == resolvedSearchPath { return c } } return nil } // ToComponents returns a copy of the components slice. func (tsc *ThreadSafeComponents) ToComponents() Components { tsc.mu.RLock() defer tsc.mu.RUnlock() // Return a copy to prevent external modification result := make(Components, len(tsc.components)) copy(result, tsc.components) return result } // Len returns the number of components in the components slice. func (tsc *ThreadSafeComponents) Len() int { tsc.mu.RLock() defer tsc.mu.RUnlock() return len(tsc.components) } ================================================ FILE: internal/component/component_test.go ================================================ package component_test import ( "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestComponentsSort(t *testing.T) { t.Parallel() // Setup configs := component.Components{ component.NewUnit("c"), component.NewUnit("a"), component.NewStack("b"), } // Act sorted := configs.Sort() // Assert require.Len(t, sorted, 3) assert.Equal(t, "a", sorted[0].Path()) assert.Equal(t, "b", sorted[1].Path()) assert.Equal(t, "c", sorted[2].Path()) } func TestComponentsFilter(t *testing.T) { t.Parallel() // Setup configs := component.Components{ component.NewUnit("unit1"), component.NewStack("stack1"), component.NewUnit("unit2"), } // Test unit filtering t.Run("filter units", func(t *testing.T) { t.Parallel() units := configs.Filter(component.UnitKind) require.Len(t, units, 2) assert.Equal(t, component.UnitKind, units[0].Kind()) assert.Equal(t, component.UnitKind, units[1].Kind()) assert.ElementsMatch(t, []string{"unit1", "unit2"}, units.Paths()) }) // Test stack filtering t.Run("filter stacks", func(t *testing.T) { t.Parallel() stacks := configs.Filter(component.StackKind) require.Len(t, stacks, 1) assert.Equal(t, component.StackKind, stacks[0].Kind()) assert.Equal(t, "stack1", stacks[0].Path()) }) } func TestComponentsCycleCheck(t *testing.T) { t.Parallel() tests := []struct { setupFunc func() component.Components name string errorExpected bool }{ { name: "no cycles", setupFunc: func() component.Components { a := component.NewUnit("a") b := component.NewUnit("b") a.AddDependency(b) return component.Components{a, b} }, errorExpected: false, }, { name: "direct cycle", setupFunc: func() component.Components { a := component.NewUnit("a") b := component.NewUnit("b") a.AddDependency(b) b.AddDependency(a) return component.Components{a, b} }, errorExpected: true, }, { name: "indirect cycle", setupFunc: func() component.Components { a := component.NewUnit("a") b := component.NewUnit("b") c := component.NewUnit("c") a.AddDependency(b) b.AddDependency(c) c.AddDependency(a) return component.Components{a, b, c} }, errorExpected: true, }, { name: "diamond dependency - no cycle", setupFunc: func() component.Components { a := component.NewUnit("a") b := component.NewUnit("b") c := component.NewUnit("c") d := component.NewUnit("d") a.AddDependency(b) a.AddDependency(c) b.AddDependency(d) c.AddDependency(d) return component.Components{a, b, c, d} }, errorExpected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() configs := tt.setupFunc() cfg, err := configs.CycleCheck() if tt.errorExpected { require.Error(t, err) assert.Contains(t, err.Error(), "cycle detected") assert.NotNil(t, cfg) } else { require.NoError(t, err) assert.Nil(t, cfg) } }) } } func TestUnitStringConcurrent(t *testing.T) { t.Parallel() unit := component.NewUnit("/test/path") dep := component.NewUnit("/test/dep") unit.AddDependency(dep) var wg sync.WaitGroup const goroutines = 10 for range goroutines { wg.Add(1) go func() { defer wg.Done() for range 100 { s := unit.String() assert.Contains(t, s, "/test/path") } }() } wg.Wait() } func TestThreadSafeComponentsEnsureNoDuplicates(t *testing.T) { t.Parallel() tsc := component.NewThreadSafeComponents(component.Components{}) // Add same path twice - should not duplicate unit1 := component.NewUnit("/test/path") unit2 := component.NewUnit("/test/path") added1, wasAdded1 := tsc.EnsureComponent(unit1) added2, wasAdded2 := tsc.EnsureComponent(unit2) assert.True(t, wasAdded1, "first component should be added") assert.False(t, wasAdded2, "second component should not be added (duplicate)") assert.Same(t, added1, added2, "should return same component instance") assert.Equal(t, 1, tsc.Len(), "should have exactly one component") } func TestThreadSafeComponentsFindByPath(t *testing.T) { t.Parallel() unit := component.NewUnit("/test/path") tsc := component.NewThreadSafeComponents(component.Components{unit}) // Find by exact path found := tsc.FindByPath("/test/path") assert.NotNil(t, found, "should find component by exact path") assert.Equal(t, "/test/path", found.Path()) // Find non-existent path notFound := tsc.FindByPath("/nonexistent") assert.Nil(t, notFound, "should not find non-existent path") } func TestThreadSafeComponentsConcurrentAccess(t *testing.T) { t.Parallel() tsc := component.NewThreadSafeComponents(component.Components{}) var wg sync.WaitGroup const goroutines = 10 // Concurrent writes tests for range goroutines { wg.Go(func() { unit := component.NewUnit("/test/path") tsc.EnsureComponent(unit) }) } // Concurrent reads for range goroutines { wg.Go(func() { for range 100 { _ = tsc.FindByPath("/test/path") _ = tsc.Len() _ = tsc.ToComponents() } }) } wg.Wait() // Should have exactly one component despite concurrent adds assert.Equal(t, 1, tsc.Len(), "should have exactly one component after concurrent adds") } ================================================ FILE: internal/component/stack.go ================================================ package component import ( "fmt" "path/filepath" "slices" "sort" "strings" "sync" "github.com/gruntwork-io/terragrunt/pkg/config" ) const ( StackKind Kind = "stack" ) // Stack represents a discovered Terragrunt stack configuration. type Stack struct { cfg *config.StackConfig discoveryContext *DiscoveryContext path string reading []string dependencies Components dependents Components Units []*Unit mu sync.RWMutex external bool } // NewStack creates a new Stack component with the given path. func NewStack(path string) *Stack { return &Stack{ path: path, discoveryContext: &DiscoveryContext{}, dependencies: make(Components, 0), dependents: make(Components, 0), } } // WithDiscoveryContext sets the discovery context for this stack. func (s *Stack) WithDiscoveryContext(ctx *DiscoveryContext) *Stack { s.discoveryContext = ctx return s } // Config returns the parsed Stack configuration for this stack. func (s *Stack) Config() *config.StackConfig { return s.cfg } // StoreConfig stores the parsed Stack configuration for this stack. func (s *Stack) StoreConfig(cfg *config.StackConfig) { s.cfg = cfg } // Kind returns the kind of component (always Stack for Stack). func (s *Stack) Kind() Kind { return StackKind } // Path returns the path to the component. func (s *Stack) Path() string { return s.path } // SetPath sets the path to the component. func (s *Stack) SetPath(path string) { s.path = path } // DisplayPath returns the path relative to DiscoveryContext.WorkingDir for display purposes. // Falls back to the original path if relative path calculation fails or WorkingDir is empty. func (s *Stack) DisplayPath() string { if s.discoveryContext == nil || s.discoveryContext.WorkingDir == "" { return s.path } if rel, err := filepath.Rel(s.discoveryContext.WorkingDir, s.path); err == nil { return rel } return s.path } // External returns whether the component is external. func (s *Stack) External() bool { return s.external } // SetExternal marks the component as external. func (s *Stack) SetExternal() { s.external = true } // Reading returns the list of files being read by this component. func (s *Stack) Reading() []string { return s.reading } // SetReading sets the list of files being read by this component. func (s *Stack) SetReading(files ...string) { s.reading = files } // Sources returns the list of sources for this component. // // Stacks don't support leveraging sources right now, so we just return an empty list. func (s *Stack) Sources() []string { return []string{} } // ConfigFile returns the config filename for this stack. func (s *Stack) ConfigFile() string { return config.DefaultStackFile } // DiscoveryContext returns the discovery context for this component. func (s *Stack) DiscoveryContext() *DiscoveryContext { return s.discoveryContext } // SetDiscoveryContext sets the discovery context for this component. func (s *Stack) SetDiscoveryContext(ctx *DiscoveryContext) { s.discoveryContext = ctx } // Origin returns the origin of the discovery context for this component. func (s *Stack) Origin() Origin { if s.discoveryContext == nil { return OriginUnknown } return s.discoveryContext.Origin() } // lock locks the Stack. func (s *Stack) lock() { s.mu.Lock() } // unlock unlocks the Stack. func (s *Stack) unlock() { s.mu.Unlock() } // rLock locks the Stack for reading. func (s *Stack) rLock() { s.mu.RLock() } // rUnlock unlocks the Stack for reading. func (s *Stack) rUnlock() { s.mu.RUnlock() } // AddDependency adds a dependency to the Stack and vice versa. // // Using this method ensure that the dependency graph is properly maintained, // making it easier to look up dependents and dependencies on a given component // without the entire graph available. func (s *Stack) AddDependency(dependency Component) { s.ensureDependency(dependency) dependency.ensureDependent(s) } // ensureDependency adds a dependency to a stack if it's not already present. func (s *Stack) ensureDependency(dependency Component) { s.lock() defer s.unlock() if !slices.Contains(s.dependencies, dependency) { s.dependencies = append(s.dependencies, dependency) } } // ensureDependent adds a dependent to a stack if it's not already present. func (s *Stack) ensureDependent(dependent Component) { s.lock() defer s.unlock() if !slices.Contains(s.dependents, dependent) { s.dependents = append(s.dependents, dependent) } } // AddDependent adds a dependent to the Stack and vice versa. // // Using this method ensure that the dependency graph is properly maintained, // making it easier to look up dependents and dependencies on a given component // without the entire graph available. func (s *Stack) AddDependent(dependent Component) { s.ensureDependent(dependent) dependent.ensureDependency(s) } // Dependencies returns the dependencies of the Stack. func (s *Stack) Dependencies() Components { s.rLock() defer s.rUnlock() return s.dependencies } // Dependents returns the dependents of the Stack. func (s *Stack) Dependents() Components { s.rLock() defer s.rUnlock() return s.dependents } // String renders this stack as a human-readable string. // // Example output: // // Stack at /path/to/stack: // => Unit /path/to/unit1 (excluded: false, assume applied: false, dependencies: [/dep1]) // => Unit /path/to/unit2 (excluded: true, assume applied: false, dependencies: []) func (s *Stack) String() string { units := make([]string, 0, len(s.Units)) for _, unit := range s.Units { units = append(units, " => "+unit.String()) } sort.Strings(units) workingDir := s.path if s.discoveryContext != nil && s.discoveryContext.WorkingDir != "" { workingDir = s.discoveryContext.WorkingDir } return fmt.Sprintf("Stack at %s:\n%s", workingDir, strings.Join(units, "\n")) } // FindUnitByPath finds a unit in the stack by its path. func (s *Stack) FindUnitByPath(path string) *Unit { for _, unit := range s.Units { if unit.Path() == path { return unit } } return nil } ================================================ FILE: internal/component/unit.go ================================================ package component import ( "fmt" "path/filepath" "slices" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" ) const ( UnitKind Kind = "unit" ) // Unit represents a discovered Terragrunt unit configuration. type Unit struct { cfg *config.TerragruntConfig discoveryContext *DiscoveryContext path string configFile string reading []string dependencies Components dependents Components mu sync.RWMutex external bool excluded bool } // NewUnit creates a new Unit component with the given path. func NewUnit(path string) *Unit { return &Unit{ path: path, configFile: config.DefaultTerragruntConfigPath, discoveryContext: &DiscoveryContext{}, dependencies: make(Components, 0), dependents: make(Components, 0), } } // WithReading appends a file to the list of files being read by this component. // Useful for constructing components with all files read at once. func (u *Unit) WithReading(files ...string) *Unit { u.SetReading(files...) return u } // WithConfig adds configuration to a Unit component. func (u *Unit) WithConfig(cfg *config.TerragruntConfig) *Unit { u.cfg = cfg return u } // WithDiscoveryContext sets the discovery context for this unit. func (u *Unit) WithDiscoveryContext(ctx *DiscoveryContext) *Unit { u.discoveryContext = ctx return u } // Config returns the parsed Terragrunt configuration for this unit. func (u *Unit) Config() *config.TerragruntConfig { return u.cfg } // StoreConfig stores the parsed Terragrunt configuration for this unit. func (u *Unit) StoreConfig(cfg *config.TerragruntConfig) { u.cfg = cfg } // ConfigFile returns the discovered config filename for this unit. func (u *Unit) ConfigFile() string { return u.configFile } // SetConfigFile sets the discovered config filename for this unit. func (u *Unit) SetConfigFile(filename string) { u.configFile = filename } // Kind returns the kind of component (always Unit for Unit). func (u *Unit) Kind() Kind { return UnitKind } // Path returns the path to the component. func (u *Unit) Path() string { return u.path } // SetPath sets the path to the component. func (u *Unit) SetPath(path string) { u.path = path } // External returns whether the component is external. func (u *Unit) External() bool { return u.external } // SetExternal marks the component as external. func (u *Unit) SetExternal() { u.external = true } // Excluded returns whether the unit was excluded during discovery/filtering. func (u *Unit) Excluded() bool { return u.excluded } // SetExcluded marks the unit as excluded during discovery/filtering. func (u *Unit) SetExcluded(excluded bool) { u.excluded = excluded } // Reading returns the list of files being read by this component. func (u *Unit) Reading() []string { return u.reading } // SetReading sets the list of files being read by this component. func (u *Unit) SetReading(files ...string) { u.reading = files } // Sources returns the list of sources for this component. func (u *Unit) Sources() []string { if u.cfg == nil || u.cfg.Terraform == nil || u.cfg.Terraform.Source == nil { return []string{} } return []string{*u.cfg.Terraform.Source} } // DiscoveryContext returns the discovery context for this component. func (u *Unit) DiscoveryContext() *DiscoveryContext { return u.discoveryContext } // SetDiscoveryContext sets the discovery context for this component. func (u *Unit) SetDiscoveryContext(ctx *DiscoveryContext) { u.discoveryContext = ctx } // Origin returns the origin of the discovery context for this component. func (u *Unit) Origin() Origin { if u.discoveryContext == nil { return OriginUnknown } return u.discoveryContext.Origin() } // lock locks the Unit. func (u *Unit) lock() { u.mu.Lock() } // unlock unlocks the Unit. func (u *Unit) unlock() { u.mu.Unlock() } // rLock locks the Unit for reading. func (u *Unit) rLock() { u.mu.RLock() } // rUnlock unlocks the Unit for reading. func (u *Unit) rUnlock() { u.mu.RUnlock() } // AddDependency adds a dependency to the Unit and vice versa. // // Using this method ensure that the dependency graph is properly maintained, // making it easier to look up dependents and dependencies on a given component // without the entire graph available. func (u *Unit) AddDependency(dependency Component) { u.ensureDependency(dependency) dependency.ensureDependent(u) } // ensureDependency adds a dependency to a unit if it's not already present. func (u *Unit) ensureDependency(dependency Component) { u.lock() defer u.unlock() if !slices.Contains(u.dependencies, dependency) { u.dependencies = append(u.dependencies, dependency) } } // ensureDependent adds a dependent to a unit if it's not already present. func (u *Unit) ensureDependent(dependent Component) { u.lock() defer u.unlock() if !slices.Contains(u.dependents, dependent) { u.dependents = append(u.dependents, dependent) } } // AddDependent adds a dependent to the Unit and vice versa. // // Using this method ensure that the dependency graph is properly maintained, // making it easier to look up dependents and dependencies on a given component // without the entire graph available. func (u *Unit) AddDependent(dependent Component) { u.ensureDependent(dependent) dependent.ensureDependency(u) } // Dependencies returns the dependencies of the Unit. func (u *Unit) Dependencies() Components { u.rLock() defer u.rUnlock() return u.dependencies } // Dependents returns the dependents of the Unit. func (u *Unit) Dependents() Components { u.rLock() defer u.rUnlock() return u.dependents } // String renders this unit as a human-readable string for debugging. // // Example output: // // Unit /path/to/unit (excluded: false, assume applied: false, dependencies: [/dep1, /dep2]) func (u *Unit) String() string { // Snapshot values under read lock to avoid data races u.rLock() defer u.rUnlock() path := u.DisplayPath() deps := make([]string, 0, len(u.dependencies)) for _, dep := range u.dependencies { deps = append(deps, dep.DisplayPath()) } return fmt.Sprintf( "Unit %s (excluded: %v, dependencies: [%s])", path, u.excluded, strings.Join(deps, ", "), ) } // DisplayPath returns the path relative to DiscoveryContext.WorkingDir for display purposes. // Falls back to the original path if relative path calculation fails or WorkingDir is empty. func (u *Unit) DisplayPath() string { if u.discoveryContext == nil || u.discoveryContext.WorkingDir == "" { return u.path } if rel, err := filepath.Rel(u.discoveryContext.WorkingDir, u.path); err == nil { return rel } return u.path } // FindInPaths returns true if the unit is located in one of the target directories. // Paths are normalized before comparison to handle absolute/relative path mismatches. func (u *Unit) FindInPaths(targetDirs []string) bool { cleanUnitPath := filepath.Clean(u.path) for _, dir := range targetDirs { cleanDir := filepath.Clean(dir) if util.HasPathPrefix(cleanUnitPath, cleanDir) { return true } } return false } // PlanFile returns plan file location if output folder is set. func (u *Unit) PlanFile(rootWorkingDir, outputFolder, jsonOutputFolder, tofuCommand string) string { planFile := u.OutputFile(rootWorkingDir, outputFolder) planCommand := tofuCommand == tf.CommandNamePlan || tofuCommand == tf.CommandNameShow // if JSON output enabled and no PlanFile specified, save plan in working dir if planCommand && planFile == "" && jsonOutputFolder != "" { planFile = tf.TerraformPlanFile } return planFile } // OutputFile returns plan file location if output folder is set. func (u *Unit) OutputFile(rootWorkingDir, outputFolder string) string { return u.planFilePath(rootWorkingDir, outputFolder, tf.TerraformPlanFile) } // OutputJSONFile returns plan JSON file location if JSON output folder is set. func (u *Unit) OutputJSONFile(rootWorkingDir, jsonOutputFolder string) string { return u.planFilePath(rootWorkingDir, jsonOutputFolder, tf.TerraformPlanJSONFile) } // planFilePath computes the path for plan output files. func (u *Unit) planFilePath(rootWorkingDir, outputFolder, fileName string) string { if outputFolder == "" { return "" } // Use discoveryContext.WorkingDir as base (always populated). // This is critical for git-based filters where units are discovered in temporary worktrees. // Using rootWorkingDir would cause relative paths to escape the outputFolder. relPath, err := filepath.Rel(u.discoveryContext.WorkingDir, u.path) if err != nil { relPath = u.path } dir := filepath.Join(outputFolder, relPath) if !filepath.IsAbs(dir) { dir = filepath.Join(rootWorkingDir, dir) } dir = filepath.Clean(dir) return filepath.Join(dir, fileName) } ================================================ FILE: internal/component/unit_output.go ================================================ package component import ( "fmt" "io" "sync" ) // flusher is any writer that supports Flush() error. type flusher interface { Flush() error } // writerUnwrapper is any writer that can provide its underlying parent writer. // This is used to create writer-based locks that serialize flushes to the same parent. type writerUnwrapper interface { Unwrap() io.Writer } // unitOutputLocks provides locks for serializing flushes to the same parent writer. // The key is the parent writer's address (via fmt.Sprintf("%p", writer)). var unitOutputLocks sync.Map // map[string]*sync.Mutex func unitOutputLock(key string) *sync.Mutex { if mu, ok := unitOutputLocks.Load(key); ok { return mu.(*sync.Mutex) } newMu := &sync.Mutex{} actual, loaded := unitOutputLocks.LoadOrStore(key, newMu) if loaded { return actual.(*sync.Mutex) } return newMu } // FlushOutput flushes buffer data to the given writer for this unit, if the writer supports it. // This is safe to call even if u or w is nil. func FlushOutput(u *Unit, w io.Writer) error { if u == nil || w == nil { return nil } writer, ok := w.(flusher) if !ok { return nil } // Use parent writer's address as lock key to serialize flushes to same parent. // Falls back to unit path for writers without writerUnwrapper. key := u.Path() if u, ok := w.(writerUnwrapper); ok { key = fmt.Sprintf("%p", u.Unwrap()) } mu := unitOutputLock(key) mu.Lock() defer mu.Unlock() return writer.Flush() } ================================================ FILE: internal/configbridge/bridge.go ================================================ // Package configbridge provides an adapter between *options.TerragruntOptions // and *config.ParsingContext, allowing callers that have TerragruntOptions to // invoke pkg/config functions without config needing to import pkg/options. package configbridge import ( "context" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // NewParsingContext creates a config.ParsingContext populated from TerragruntOptions. func NewParsingContext(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (context.Context, *config.ParsingContext) { ctx, pctx := config.NewParsingContext(ctx, l, config.WithStrictControls(opts.StrictControls)) populateFromOpts(pctx, opts) return ctx, pctx } // populateFromOpts copies fields from TerragruntOptions into ParsingContext flat fields. func populateFromOpts(pctx *config.ParsingContext, opts *options.TerragruntOptions) { pctx.TerragruntConfigPath = opts.TerragruntConfigPath pctx.OriginalTerragruntConfigPath = opts.OriginalTerragruntConfigPath pctx.WorkingDir = opts.WorkingDir pctx.RootWorkingDir = opts.RootWorkingDir pctx.DownloadDir = opts.DownloadDir pctx.TerraformCommand = opts.TerraformCommand pctx.OriginalTerraformCommand = opts.OriginalTerraformCommand pctx.TerraformCliArgs = opts.TerraformCliArgs pctx.Source = opts.Source pctx.SourceMap = opts.SourceMap pctx.Experiments = opts.Experiments pctx.StrictControls = opts.StrictControls pctx.FeatureFlags = opts.FeatureFlags pctx.Writers = opts.Writers pctx.Env = opts.Env pctx.IAMRoleOptions = opts.IAMRoleOptions pctx.OriginalIAMRoleOptions = opts.OriginalIAMRoleOptions pctx.UsePartialParseConfigCache = opts.UsePartialParseConfigCache pctx.MaxFoldersToCheck = opts.MaxFoldersToCheck pctx.NoDependencyFetchOutputFromState = opts.NoDependencyFetchOutputFromState pctx.SkipOutput = opts.SkipOutput pctx.TFPathExplicitlySet = opts.TFPathExplicitlySet pctx.AuthProviderCmd = opts.AuthProviderCmd pctx.EngineConfig = opts.EngineConfig pctx.EngineOptions = opts.EngineOptions pctx.TFPath = opts.TFPath pctx.TofuImplementation = opts.TofuImplementation pctx.ForwardTFStdout = opts.ForwardTFStdout pctx.JSONLogFormat = opts.JSONLogFormat pctx.Debug = opts.Debug pctx.AutoInit = opts.AutoInit pctx.Headless = opts.Headless pctx.BackendBootstrap = opts.BackendBootstrap pctx.CheckDependentUnits = opts.CheckDependentUnits pctx.Telemetry = opts.Telemetry pctx.NoStackValidate = opts.NoStackValidate pctx.ScaffoldRootFileName = opts.ScaffoldRootFileName pctx.TerragruntStackConfigPath = opts.TerragruntStackConfigPath pctx.ProviderCacheOptions = opts.ProviderCacheOptions } // ShellRunOptsFromOpts constructs shell.ShellOptions from TerragruntOptions. func ShellRunOptsFromOpts(opts *options.TerragruntOptions) *shell.ShellOptions { return &shell.ShellOptions{ Writers: opts.Writers, EngineOptions: opts.EngineOptions, WorkingDir: opts.WorkingDir, Env: opts.Env, TFPath: opts.TFPath, EngineConfig: opts.EngineConfig, Experiments: opts.Experiments, Telemetry: opts.Telemetry, RootWorkingDir: opts.RootWorkingDir, Headless: opts.Headless, ForwardTFStdout: opts.ForwardTFStdout, } } // BackendOptsFromOpts constructs backend.Options from TerragruntOptions. func BackendOptsFromOpts(opts *options.TerragruntOptions) *backend.Options { return &backend.Options{ Writers: opts.Writers, Env: opts.Env, IAMRoleOptions: opts.IAMRoleOptions, NonInteractive: opts.NonInteractive, FailIfBucketCreationRequired: opts.FailIfBucketCreationRequired, } } // RemoteStateOptsFromOpts constructs remotestate.Options from TerragruntOptions. func RemoteStateOptsFromOpts(opts *options.TerragruntOptions) *remotestate.Options { return &remotestate.Options{ Options: *BackendOptsFromOpts(opts), DisableBucketUpdate: opts.DisableBucketUpdate, TFRunOpts: TFRunOptsFromOpts(opts), } } // TFRunOptsFromOpts constructs tf.TFOptions from TerragruntOptions. func TFRunOptsFromOpts(opts *options.TerragruntOptions) *tf.TFOptions { return &tf.TFOptions{ JSONLogFormat: opts.JSONLogFormat, OriginalTerragruntConfigPath: opts.OriginalTerragruntConfigPath, TerragruntConfigPath: opts.TerragruntConfigPath, TofuImplementation: opts.TofuImplementation, TerraformCliArgs: opts.TerraformCliArgs, ShellOptions: ShellRunOptsFromOpts(opts), } } // NewRunOptions creates a run.Options from TerragruntOptions. // This replaces the former run.NewOptions(opts) function. func NewRunOptions(opts *options.TerragruntOptions) *run.Options { return &run.Options{ Writers: opts.Writers, TerragruntConfigPath: opts.TerragruntConfigPath, OriginalTerragruntConfigPath: opts.OriginalTerragruntConfigPath, WorkingDir: opts.WorkingDir, RootWorkingDir: opts.RootWorkingDir, DownloadDir: opts.DownloadDir, TerraformCommand: opts.TerraformCommand, OriginalTerraformCommand: opts.OriginalTerraformCommand, TerraformCliArgs: opts.TerraformCliArgs, Source: opts.Source, SourceMap: opts.SourceMap, Env: opts.Env, IAMRoleOptions: opts.IAMRoleOptions, OriginalIAMRoleOptions: opts.OriginalIAMRoleOptions, EngineConfig: opts.EngineConfig, EngineOptions: opts.EngineOptions, Errors: opts.Errors, Experiments: opts.Experiments, StrictControls: opts.StrictControls, FeatureFlags: opts.FeatureFlags, TFPath: opts.TFPath, TofuImplementation: opts.TofuImplementation, ForwardTFStdout: opts.ForwardTFStdout, JSONLogFormat: opts.JSONLogFormat, Headless: opts.Headless, NonInteractive: opts.NonInteractive, Debug: opts.Debug, AutoInit: opts.AutoInit, AutoRetry: opts.AutoRetry, BackendBootstrap: opts.BackendBootstrap, Telemetry: opts.Telemetry, AuthProviderCmd: opts.AuthProviderCmd, MaxFoldersToCheck: opts.MaxFoldersToCheck, FailIfBucketCreationRequired: opts.FailIfBucketCreationRequired, DisableBucketUpdate: opts.DisableBucketUpdate, SourceUpdate: opts.SourceUpdate, } } ================================================ FILE: internal/ctyhelper/helper.go ================================================ // Package ctyhelper providers helpful tools for working with cty values. // //nolint:dupl package ctyhelper import ( "encoding/json" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/gruntwork-io/terragrunt/internal/errors" ) // ParseCtyValueToMap converts a cty.Value to a map[string]any. // // This is a hacky workaround to convert a cty Value to a Go map[string]any. cty does not support this directly // (https://github.com/hashicorp/hcl2/issues/108) and doing it with gocty.FromCtyValue is nearly impossible, as cty // requires you to specify all the output types and will error out when it hits interface{}. So, as an ugly workaround, // we convert the given value to JSON using cty's JSON library and then convert the JSON back to a // map[string]any using the Go json library. // // Note: This function will strip any marks (such as sensitive marks) from the values because JSON serialization does // not support cty marks. If you need to preserve marks, consider working with cty.Value directly instead of converting // to map[string]any. func ParseCtyValueToMap(value cty.Value) (map[string]any, error) { if value.IsNull() { return map[string]any{}, nil } updatedValue, err := UpdateUnknownCtyValValues(value) if err != nil { return nil, err } value = updatedValue // Unmark the value (including nested values) before JSON serialization as JSON doesn't support marks. unmarkedValue, _ := value.UnmarkDeep() jsonBytes, err := ctyjson.Marshal(unmarkedValue, cty.DynamicPseudoType) if err != nil { return nil, errors.New(err) } var ctyJSONOutput CtyJSONOutput if err := json.Unmarshal(jsonBytes, &ctyJSONOutput); err != nil { return nil, errors.New(err) } return ctyJSONOutput.Value, nil } // CtyJSONOutput is a struct that captures the output of cty's JSON marshalling. // // When you convert a cty value to JSON, if any of that types are not yet known (i.e., are labeled as // DynamicPseudoType), cty's Marshall method will write the type information to a type field and the actual value to // a value field. This struct is used to capture that information so when we parse the JSON back into a Go struct, we // can pull out just the Value field we need. type CtyJSONOutput struct { Value map[string]any `json:"Value"` Type any `json:"Type"` } // UpdateUnknownCtyValValues deeply updates unknown values with default value func UpdateUnknownCtyValValues(value cty.Value) (cty.Value, error) { var updatedValue any switch { case !value.IsKnown(): return cty.StringVal(""), nil case value.IsNull(): return value, nil case value.Type().IsMapType(), value.Type().IsObjectType(): mapVals := value.AsValueMap() for key, val := range mapVals { val, err := UpdateUnknownCtyValValues(val) if err != nil { return cty.NilVal, err } mapVals[key] = val } if len(mapVals) > 0 { updatedValue = mapVals } case value.Type().IsTupleType(), value.Type().IsListType(): sliceVals := value.AsValueSlice() for key, val := range sliceVals { val, err := UpdateUnknownCtyValValues(val) if err != nil { return cty.NilVal, err } sliceVals[key] = val } if len(sliceVals) > 0 { updatedValue = sliceVals } } if updatedValue == nil { return value, nil } value, err := gocty.ToCtyValue(updatedValue, value.Type()) if err != nil { return cty.NilVal, errors.New(err) } return value, nil } ================================================ FILE: internal/ctyhelper/helper_test.go ================================================ package ctyhelper_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func TestUpdateUnknownCtyValValues(t *testing.T) { t.Parallel() testCases := []struct { value cty.Value expectedValue cty.Value }{ { cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ "items": cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ "firstname": cty.StringVal("foo"), "lastname": cty.UnknownVal(cty.String), })}), })}), cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ "items": cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ "firstname": cty.StringVal("foo"), "lastname": cty.StringVal(""), })}), })}), }, { cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{})}), cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{})}), }, { cty.ObjectVal(map[string]cty.Value{}), cty.ObjectVal(map[string]cty.Value{}), }, { cty.ObjectVal(map[string]cty.Value{"key": cty.UnknownVal(cty.String)}), cty.ObjectVal(map[string]cty.Value{"key": cty.StringVal("")}), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actualValue, err := ctyhelper.UpdateUnknownCtyValValues(tc.value) require.NoError(t, err) assert.Equal(t, tc.expectedValue, actualValue) }) } } ================================================ FILE: internal/discovery/benchmark_test.go ================================================ package discovery_test import ( "fmt" "io" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/stretchr/testify/require" ) // unitCounts defines geometric scaling for benchmark fixture sizes. var unitCounts = []int{64, 128, 256, 512, 1024} func BenchmarkDiscovery(b *testing.B) { b.Run("path_expression", func(b *testing.B) { for _, n := range unitCounts { b.Run(fmt.Sprintf("units_%d", n), func(b *testing.B) { benchmarkPathExpression(b, n) }) } }) b.Run("graph_expression", func(b *testing.B) { for _, n := range unitCounts { b.Run(fmt.Sprintf("units_%d", n), func(b *testing.B) { benchmarkGraphExpression(b, n) }) } }) b.Run("path_and_graph_expression", func(b *testing.B) { for _, n := range unitCounts { b.Run(fmt.Sprintf("units_%d", n), func(b *testing.B) { benchmarkPathAndGraphExpression(b, n) }) } }) } // benchmarkPathExpression benchmarks discovery with a path-only filter. // Targets 2 app units; only filesystem classification runs, no parsing occurs. func benchmarkPathExpression(b *testing.B, n int) { b.Helper() tmpDir := b.TempDir() createFixtures(b, tmpDir, n) l := newDiscardLogger() opts := &options.TerragruntOptions{WorkingDir: tmpDir, RootWorkingDir: tmpDir} filterQueries, err := filter.ParseFilterQueries(l, []string{"./apps/app-0000", "./apps/app-0001"}) require.NoError(b, err) b.ResetTimer() for b.Loop() { d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filterQueries). WithSuppressParseErrors() components, err := d.Discover(b.Context(), l, opts) require.NoError(b, err) require.Len(b, components, 2) } } // benchmarkGraphExpression benchmarks discovery with a graph-only filter. // Targets a shallow 2-unit dependency pair (infra-0001 → infra-0000). func benchmarkGraphExpression(b *testing.B, n int) { b.Helper() tmpDir := b.TempDir() createFixtures(b, tmpDir, n) l := newDiscardLogger() opts := &options.TerragruntOptions{WorkingDir: tmpDir, RootWorkingDir: tmpDir} filterQueries, err := filter.ParseFilterQueries(l, []string{"infra-0001..."}) require.NoError(b, err) b.ResetTimer() for b.Loop() { d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filterQueries). WithSuppressParseErrors() components, err := d.Discover(b.Context(), l, opts) require.NoError(b, err) require.Len(b, components, 2) } } // benchmarkPathAndGraphExpression benchmarks discovery with combined path + graph filters. // Targets 2 path-matched apps + 2 graph-traversed infra units (infra-0001 → infra-0000). func benchmarkPathAndGraphExpression(b *testing.B, n int) { b.Helper() tmpDir := b.TempDir() createFixtures(b, tmpDir, n) l := newDiscardLogger() opts := &options.TerragruntOptions{WorkingDir: tmpDir, RootWorkingDir: tmpDir} filterQueries, err := filter.ParseFilterQueries(l, []string{"./apps/app-0000", "./apps/app-0001", "infra-0001..."}) require.NoError(b, err) b.ResetTimer() for b.Loop() { d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filterQueries). WithSuppressParseErrors() components, err := d.Discover(b.Context(), l, opts) require.NoError(b, err) require.Len(b, components, 4) } } func newDiscardLogger() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithOutput(io.Discard), log.WithFormatter(formatter)) } // createFixtures creates a fixture layout with n total units: // - n/2 "app" units in apps/app-NNNN/terragrunt.hcl (minimal, no dependencies) // - n/2 "infra" units in infra/infra-NNNN/terragrunt.hcl (paired dependency chains: // odd-numbered units depend on the preceding even unit, e.g. infra-0001 → infra-0000) func createFixtures(b *testing.B, tmpDir string, n int) { b.Helper() half := n / 2 appsDir := filepath.Join(tmpDir, "apps") for i := range half { dir := filepath.Join(appsDir, fmt.Sprintf("app-%04d", i)) require.NoError(b, os.MkdirAll(dir, 0755)) require.NoError(b, os.WriteFile( filepath.Join(dir, "terragrunt.hcl"), []byte("# Minimal config\n"), 0644, )) } infraDir := filepath.Join(tmpDir, "infra") for i := range half { dir := filepath.Join(infraDir, fmt.Sprintf("infra-%04d", i)) require.NoError(b, os.MkdirAll(dir, 0755)) var content string if i%2 == 1 { prev := fmt.Sprintf("infra-%04d", i-1) content = fmt.Sprintf("dependency \"prev\" {\n config_path = \"../%s\"\n}\n", prev) } else { content = "# Leaf unit\n" } require.NoError(b, os.WriteFile( filepath.Join(dir, "terragrunt.hcl"), []byte(content), 0644, )) } } ================================================ FILE: internal/discovery/constructor.go ================================================ package discovery import ( "path/filepath" "runtime" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mattn/go-shellwords" ) // DiscoveryCommandOptions contains options for discovery commands like find and list. type DiscoveryCommandOptions struct { WorkingDir string QueueConstructAs string Filters filter.Filters Experiments experiment.Experiments NoHidden bool Exclude bool Include bool Reading bool WithRequiresParse bool WithRelationships bool } // HCLCommandOptions contains options for HCL commands like hcl validate & format. type HCLCommandOptions struct { WorkingDir string Filters filter.Filters Experiments experiment.Experiments } // StackGenerateOptions contains options for stack generate commands. type StackGenerateOptions struct { WorkingDir string Filters filter.Filters Experiments experiment.Experiments } // NewForDiscoveryCommand creates a Discovery configured for discovery commands (find/list). func NewForDiscoveryCommand(l log.Logger, opts *DiscoveryCommandOptions) (*Discovery, error) { d := NewDiscovery(opts.WorkingDir). WithSuppressParseErrors(). WithBreakCycles() if opts.NoHidden { d = d.WithNoHidden() } if opts.WithRequiresParse { d = d.WithRequiresParse() } if opts.WithRelationships { d = d.WithRelationships() } if opts.Exclude { d = d.WithParseExclude() } if opts.Include { d = d.WithParseIncludes() } if opts.Reading { d = d.WithReadFiles() } if opts.QueueConstructAs != "" { d = d.WithParseExclude() parser := shellwords.NewParser() // Normalize Windows paths before parsing - shellwords treats backslashes as escape characters args, err := parser.Parse(filepath.ToSlash(opts.QueueConstructAs)) if err != nil { return nil, err } cmd := args[0] if len(args) > 1 { args = args[1:] } else { args = nil } d = d.WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: opts.WorkingDir, Cmd: cmd, Args: args, }) } if len(opts.Filters) > 0 { d = d.WithFilters(opts.Filters) } return d, nil } // NewForHCLCommand creates a Discovery configured for HCL commands (hcl validate/format). func NewForHCLCommand(l log.Logger, opts HCLCommandOptions) (*Discovery, error) { d := NewDiscovery(opts.WorkingDir) if len(opts.Filters) > 0 { d = d.WithFilters(opts.Filters) } return d, nil } // NewForStackGenerate creates a Discovery configured for `stack generate`. func NewForStackGenerate(l log.Logger, opts StackGenerateOptions) (*Discovery, error) { d := NewDiscovery(opts.WorkingDir) if len(opts.Filters) > 0 { d = d.WithFilters(opts.Filters.RestrictToStacks()) } return d, nil } // NewDiscovery creates a new Discovery with sensible defaults. func NewDiscovery(dir string) *Discovery { numWorkers := max(min(runtime.NumCPU(), maxDiscoveryWorkers), defaultDiscoveryWorkers) return &Discovery{ numWorkers: numWorkers, maxDependencyDepth: defaultMaxDependencyDepth, workingDir: dir, configFilenames: DefaultConfigFilenames, discoveryContext: &component.DiscoveryContext{ WorkingDir: dir, }, } } ================================================ FILE: internal/discovery/discovery.go ================================================ package discovery import ( "context" "path/filepath" "slices" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "golang.org/x/sync/errgroup" ) // Discover performs the full discovery process. func (d *Discovery) Discover( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, ) (component.Components, error) { d.classifier = filter.NewClassifier(d.filters) results, err := d.runFilesystemPhase(ctx, l, opts) if err != nil && (!d.suppressParseErrors || errors.As(err, new(CoexistenceError))) { return nil, err } discovered, candidates := results.Discovered, results.Candidates if d.requiresParse || d.classifier.HasParseRequiredFilters() { results, err = d.runParsePhase(ctx, l, opts, discovered, candidates) if err != nil && !d.suppressParseErrors { return nil, err } discovered, candidates = results.Discovered, results.Candidates } if d.classifier.HasGraphFilters() { if d.classifier.HasDependentFilters() && d.gitRoot == "" { if gitRootPath, gitErr := shell.GitTopLevelDir(ctx, l, opts.Env, d.workingDir); gitErr == nil { d.gitRoot = gitRootPath l.Debugf("Set gitRoot for dependent discovery: %s", d.gitRoot) } } results, err = d.runGraphPhase(ctx, l, opts, discovered, candidates) if err != nil && !d.suppressParseErrors { return nil, err } discovered = results.Discovered } components := resultsToComponents(discovered) if d.discoverRelationships { components, err = d.runRelationshipPhase(ctx, l, opts, components) if err != nil && !d.suppressParseErrors { return components, err } } if len(d.filters) > 0 { filtered, err := d.filters.Evaluate(l, components) if err != nil { return components, err } components = filtered } cycleCheckErr := telemetry.TelemeterFromContext(ctx).Collect(ctx, "discovery_cycle_check", map[string]any{}, func(childCtx context.Context) error { if _, cycleErr := components.CycleCheck(); cycleErr != nil { l.Debugf("Cycle: %v", cycleErr) if d.breakCycles { l.Warnf("Cycle detected in dependency graph, attempting removal of cycles.") var removeErr error components, removeErr = removeCycles(components) if removeErr != nil { return removeErr } } } return nil }) if cycleCheckErr != nil && !d.suppressParseErrors { return components, cycleCheckErr } if d.graphTarget != "" { components = d.filterGraphTarget(components) } components = d.applyQueueFilters(opts, components) return components, nil } // runFilesystemPhase runs the filesystem and worktree phases concurrently. func (d *Discovery) runFilesystemPhase( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, ) (*PhaseResults, error) { var ( allDiscovered []DiscoveryResult allCandidates []DiscoveryResult allErrors []error mu sync.Mutex ) // maxPhases is the maximum number of phases to run concurrently // for filesystem and worktree phases. const maxPhases = 2 g, ctx := errgroup.WithContext(ctx) g.SetLimit(maxPhases) g.Go(func() error { phase := NewFilesystemPhase(d.numWorkers) result, err := phase.Run(ctx, l, &PhaseInput{ Opts: opts, Classifier: d.classifier, Discovery: d, }) mu.Lock() if result != nil { allDiscovered = append(allDiscovered, result.Discovered...) allCandidates = append(allCandidates, result.Candidates...) } if err != nil { allErrors = append(allErrors, err) } mu.Unlock() return nil }) if len(d.gitExpressions) > 0 && d.worktrees != nil { g.Go(func() error { phase := NewWorktreePhase(d.gitExpressions, d.numWorkers) result, err := phase.Run(ctx, l, &PhaseInput{ Opts: opts, Classifier: d.classifier, Discovery: d, }) mu.Lock() if result != nil { allDiscovered = append(allDiscovered, result.Discovered...) allCandidates = append(allCandidates, result.Candidates...) } if err != nil { allErrors = append(allErrors, err) } mu.Unlock() return nil }) } if err := g.Wait(); err != nil { allErrors = append(allErrors, err) } if err := validateNoCoexistence(allDiscovered); err != nil { return nil, err } if err := validateNoCoexistence(allCandidates); err != nil { return nil, err } allDiscovered = deduplicateResults(allDiscovered) allCandidates = deduplicateResults(allCandidates) return &PhaseResults{ Discovered: allDiscovered, Candidates: allCandidates, }, errors.Join(allErrors...) } // runParsePhase runs the parse phase for candidates that require parsing. func (d *Discovery) runParsePhase( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, discovered []DiscoveryResult, candidates []DiscoveryResult, ) (*PhaseResults, error) { phase := NewParsePhase(d.numWorkers) result, err := phase.Run(ctx, l, &PhaseInput{ Opts: opts, Components: resultsToComponents(discovered), Candidates: candidates, Classifier: d.classifier, Discovery: d, }) allDiscovered := discovered if result != nil { allDiscovered = append(allDiscovered, result.Discovered...) } allDiscovered = deduplicateResults(allDiscovered) var resultCandidates []DiscoveryResult if result != nil { resultCandidates = result.Candidates } return &PhaseResults{ Discovered: allDiscovered, Candidates: resultCandidates, }, err } // runGraphPhase runs the graph traversal phase. func (d *Discovery) runGraphPhase( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, discovered []DiscoveryResult, candidates []DiscoveryResult, ) (*PhaseResults, error) { if d.classifier.HasDependentFilters() { allComponents := resultsToComponents(discovered) allComponents = append(allComponents, resultsToComponents(candidates)...) var buildErrs []error telemetry.TelemeterFromContext(ctx).Collect(ctx, "discover_dependents", map[string]any{}, func(childCtx context.Context) error { //nolint:errcheck buildErrs = d.buildDependencyGraph(childCtx, l, opts, allComponents) return errors.Join(buildErrs...) }) if len(buildErrs) > 0 && !d.suppressParseErrors { return &PhaseResults{ Discovered: discovered, Candidates: candidates, }, errors.Join(buildErrs...) } } phase := NewGraphPhase(d.numWorkers, d.maxDependencyDepth) var ( result *PhaseResults err error ) telemetry.TelemeterFromContext(ctx).Collect(ctx, "discover_dependencies", map[string]any{}, func(childCtx context.Context) error { //nolint:errcheck result, err = phase.Run(childCtx, l, &PhaseInput{ Opts: opts, Components: resultsToComponents(discovered), Candidates: candidates, Classifier: d.classifier, Discovery: d, }) return err }) allDiscovered := discovered if result != nil { allDiscovered = append(allDiscovered, result.Discovered...) } allDiscovered = deduplicateResults(allDiscovered) var resultCandidates []DiscoveryResult if result != nil { resultCandidates = result.Candidates } return &PhaseResults{ Discovered: allDiscovered, Candidates: resultCandidates, }, err } // runRelationshipPhase runs the relationship discovery phase. func (d *Discovery) runRelationshipPhase( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, components component.Components, ) (component.Components, error) { phase := NewRelationshipPhase(d.numWorkers, d.maxDependencyDepth) _, err := phase.Run(ctx, l, &PhaseInput{ Opts: opts, Components: components, Discovery: d, }) return components, err } // buildDependencyGraph parses all components and builds bidirectional dependency links. // This is called before the graph phase when dependent filters exist, to populate // the reverse links (dependents) that the graph phase needs for dependent traversal. func (d *Discovery) buildDependencyGraph( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, allComponents component.Components, ) []error { threadSafeComponents := component.NewThreadSafeComponents(allComponents) var ( errs []error mu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(d.numWorkers) for _, c := range allComponents { g.Go(func() error { err := d.buildComponentDependencies(ctx, l, opts, c, threadSafeComponents) if err != nil { mu.Lock() errs = append(errs, err) mu.Unlock() } return nil }) } err := g.Wait() if err != nil { l.Debugf("Error building dependency graph: %v", err) } return errs } // buildComponentDependencies parses a single component and builds its dependency links. func (d *Discovery) buildComponentDependencies( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, c component.Component, threadSafeComponents *component.ThreadSafeComponents, ) error { unit, ok := c.(*component.Unit) if !ok { return nil } cfg := unit.Config() if cfg == nil { err := parseComponent(ctx, l, c, opts, d) if err != nil { if d.suppressParseErrors { l.Debugf("Suppressed parse error for %s: %v", c.Path(), err) return nil } return err } cfg = unit.Config() } depPaths, err := extractDependencyPaths(cfg, c) if err != nil { return err } if len(depPaths) == 0 { return nil } parentCtx := c.DiscoveryContext() if parentCtx == nil { return nil } for _, depPath := range depPaths { depComponent := componentFromDependencyPath(depPath, threadSafeComponents) if isExternal(parentCtx.WorkingDir, depPath) { if ext, ok := depComponent.(*component.Unit); ok { ext.SetExternal() } } addedComponent, created := threadSafeComponents.EnsureComponent(depComponent) if created { copiedCtx := parentCtx.CopyWithNewOrigin(component.OriginGraphDiscovery) depComponent.SetDiscoveryContext(copiedCtx) } c.AddDependency(addedComponent) } return nil } // removeCycles removes cycles from the dependency graph. func removeCycles(components component.Components) (component.Components, error) { var ( c component.Component err error ) for range maxCycleRemovalAttempts { c, err = components.CycleCheck() if err == nil { break } if c == nil { break } components = components.RemoveByPath(c.Path()) } return components, err } // filterGraphTarget prunes components to the target path and its dependents. func (d *Discovery) filterGraphTarget(components component.Components) component.Components { if d.graphTarget == "" { return components } targetPath := canonicalizeGraphTarget(d.workingDir, d.graphTarget) dependentUnits := buildDependentsIndex(components) propagateTransitiveDependents(dependentUnits) allowed := buildAllowSet(targetPath, dependentUnits) return filterByAllowSet(components, allowed) } // canonicalizeGraphTarget resolves the graph target to an absolute, cleaned path with symlinks resolved. // Returns an error if the path cannot be made absolute. func canonicalizeGraphTarget(baseDir, target string) string { var abs string // If already absolute, just clean it if filepath.IsAbs(target) { abs = filepath.Clean(target) } else if canonicalAbs, err := util.CanonicalPath(target, baseDir); err == nil { // Try canonical path first abs = canonicalAbs } else { // Fallback: join with baseDir and clean abs = filepath.Clean(filepath.Join(baseDir, target)) } // Resolve symlinks for consistent path comparison (important on macOS where /var -> /private/var) // EvalSymlinks can fail for: non-existent paths (expected during discovery), // broken symlinks, or permission issues. In all cases, falling back to the // absolute path is acceptable - the path will be validated later when used. resolved, evalErr := filepath.EvalSymlinks(abs) if evalErr != nil { return abs } return resolved } // buildDependentsIndex builds an index mapping each unit path to the list of units // that directly depend on it. Duplicate entries are removed. // Paths are resolved to handle symlinks consistently across platforms. func buildDependentsIndex(components component.Components) map[string][]string { dependentUnits := make(map[string][]string) for _, c := range components { cPath := util.ResolvePath(c.Path()) for _, dep := range c.Dependencies() { depPath := util.ResolvePath(dep.Path()) dependentUnits[depPath] = util.RemoveDuplicates(append(dependentUnits[depPath], cPath)) } } return dependentUnits } // propagateTransitiveDependents expands the dependents index to include transitive dependents. // Iteratively propagates dependents until a fixed point is reached or the iteration cap is met. func propagateTransitiveDependents(dependentUnits map[string][]string) { // Determine an upper bound on iterations based on unique nodes in the graph (keys + values). nodes := make(map[string]struct{}) for unit, dependents := range dependentUnits { nodes[unit] = struct{}{} for _, dep := range dependents { nodes[dep] = struct{}{} } } maxIterations := len(nodes) for range maxIterations { updated := false for unit, dependents := range dependentUnits { for _, dep := range dependents { old := dependentUnits[unit] newList := util.RemoveDuplicates(append(old, dependentUnits[dep]...)) newList = slices.DeleteFunc(newList, func(path string) bool { return path == unit }) if len(newList) != len(old) { dependentUnits[unit] = newList updated = true } } } if !updated { break } } } // buildAllowSet creates the allowlist containing the target and all of its dependents. func buildAllowSet(targetPath string, dependentUnits map[string][]string) map[string]struct{} { allowed := make(map[string]struct{}) allowed[targetPath] = struct{}{} for _, dep := range dependentUnits[targetPath] { allowed[dep] = struct{}{} } return allowed } // filterByAllowSet returns only the components whose path exists in the allow set. // Paths are resolved to handle symlinks consistently across platforms. // The output order matches the input order (no sorting is performed here). func filterByAllowSet(components component.Components, allowed map[string]struct{}) component.Components { filtered := make(component.Components, 0, len(components)) for _, c := range components { resolvedPath := util.ResolvePath(c.Path()) if _, ok := allowed[resolvedPath]; ok { filtered = append(filtered, c) } } return filtered } // applyQueueFilters marks discovered units as excluded or included based on queue-related CLI flags and config. // The runner consumes the exclusion markers instead of re-evaluating the filters. func (d *Discovery) applyQueueFilters(opts *options.TerragruntOptions, components component.Components) component.Components { components = d.applyExcludeModules(opts, components) return components } // applyExcludeModules marks units (and optionally their dependencies) excluded via terragrunt exclude blocks. func (d *Discovery) applyExcludeModules(opts *options.TerragruntOptions, components component.Components) component.Components { for _, c := range components { unit, ok := c.(*component.Unit) if !ok { continue } cfg := unit.Config() if cfg == nil || cfg.Exclude == nil { continue } if !cfg.Exclude.IsActionListed(opts.TerraformCommand) { continue } if cfg.Exclude.If { unit.SetExcluded(true) if cfg.Exclude.ExcludeDependencies != nil && *cfg.Exclude.ExcludeDependencies { for _, dep := range unit.Dependencies() { depUnit, ok := dep.(*component.Unit) if !ok { continue } depUnit.SetExcluded(true) } } } } return components } ================================================ FILE: internal/discovery/discovery_integration_test.go ================================================ package discovery_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestDiscovery_BasicWithHiddenDirectories tests discovery with and without hidden directories. func TestDiscovery_BasicWithHiddenDirectories(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure unit1Dir := filepath.Join(tmpDir, "unit1") unit2Dir := filepath.Join(tmpDir, "unit2") stack1Dir := filepath.Join(tmpDir, "stack1") hiddenUnitDir := filepath.Join(tmpDir, ".hidden", "hidden-unit") nestedUnit4Dir := filepath.Join(tmpDir, "nested", "unit4") testDirs := []string{ unit1Dir, unit2Dir, stack1Dir, hiddenUnitDir, nestedUnit4Dir, } for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create test files testFiles := map[string]string{ filepath.Join(unit1Dir, "terragrunt.hcl"): "", filepath.Join(unit2Dir, "terragrunt.hcl"): "", filepath.Join(stack1Dir, "terragrunt.stack.hcl"): "", filepath.Join(hiddenUnitDir, "terragrunt.hcl"): "", filepath.Join(nestedUnit4Dir, "terragrunt.hcl"): "", } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } tests := []struct { name string wantUnits []string wantStacks []string noHidden bool }{ { name: "discovery without hidden", noHidden: true, wantUnits: []string{unit1Dir, unit2Dir, nestedUnit4Dir}, wantStacks: []string{stack1Dir}, }, { name: "discovery with hidden", noHidden: false, wantUnits: []string{unit1Dir, unit2Dir, hiddenUnitDir, nestedUnit4Dir}, wantStacks: []string{stack1Dir}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() d := discovery.NewDiscovery(tmpDir).WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tmpDir, }) if tt.noHidden { d = d.WithNoHidden() } components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() stacks := components.Filter(component.StackKind).Paths() assert.ElementsMatch(t, tt.wantUnits, units) assert.ElementsMatch(t, tt.wantStacks, stacks) }) } } // TestDiscovery_StackHiddenDiscovered tests that .terragrunt-stack directories are discovered by default. func TestDiscovery_StackHiddenDiscovered(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) stackHiddenDir := filepath.Join(tmpDir, ".terragrunt-stack", "u") require.NoError(t, os.MkdirAll(stackHiddenDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(stackHiddenDir, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // By default, .terragrunt-stack contents should be discovered d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Contains(t, components.Filter(component.UnitKind).Paths(), stackHiddenDir) } // TestDiscovery_WithDependencies tests dependency discovery and relationship building. func TestDiscovery_WithDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) internalDir := filepath.Join(tmpDir, "internal") appDir := filepath.Join(internalDir, "app") dbDir := filepath.Join(internalDir, "db") vpcDir := filepath.Join(internalDir, "vpc") externalDir := filepath.Join(tmpDir, "external") externalAppDir := filepath.Join(externalDir, "app") testDirs := []string{ appDir, dbDir, vpcDir, externalAppDir, } for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } dependency "external" { config_path = "../../external/app" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, filepath.Join(externalAppDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: internalDir, RootWorkingDir: internalDir, } ctx := t.Context() t.Run("discovery with relationships", func(t *testing.T) { t.Parallel() d := discovery.NewDiscovery(internalDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}). WithRelationships() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should discover all internal components paths := components.Paths() assert.Contains(t, paths, appDir) assert.Contains(t, paths, dbDir) assert.Contains(t, paths, vpcDir) // Find app component and verify dependencies var appComponent component.Component for _, c := range components { if c.Path() == appDir { appComponent = c break } } require.NotNil(t, appComponent, "app component should be discovered") depPaths := appComponent.Dependencies().Paths() assert.Contains(t, depPaths, dbDir, "app should depend on db") assert.Contains(t, depPaths, externalAppDir, "app should depend on external app") // Verify db's dependencies var dbComponent component.Component for _, c := range components { if c.Path() == dbDir { dbComponent = c break } } require.NotNil(t, dbComponent) assert.Contains(t, dbComponent.Dependencies().Paths(), vpcDir, "db should depend on vpc") }) t.Run("discovery with dependency graph filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(internalDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should discover all components including external dependency paths := components.Paths() assert.Contains(t, paths, appDir) assert.Contains(t, paths, dbDir) assert.Contains(t, paths, vpcDir) assert.Contains(t, paths, externalAppDir) // Find external app and verify it's marked as external for _, c := range components { if c.Path() == externalAppDir { assert.True(t, c.External(), "external app should be marked as external") break } } }) } // TestDiscovery_CycleDetection tests that cycles in dependency graphs are detected. func TestDiscovery_CycleDetection(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) fooDir := filepath.Join(tmpDir, "foo") barDir := filepath.Join(tmpDir, "bar") testDirs := []string{fooDir, barDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create terragrunt.hcl files with mutual dependencies (cycle) testFiles := map[string]string{ filepath.Join(fooDir, "terragrunt.hcl"): ` dependency "bar" { config_path = "../bar" } `, filepath.Join(barDir, "terragrunt.hcl"): ` dependency "foo" { config_path = "../foo" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err, "Discovery should complete even with cycles") // Verify that a cycle is detected cycleComponent, cycleErr := components.CycleCheck() require.Error(t, cycleErr, "Cycle check should detect a cycle between foo and bar") assert.Contains(t, cycleErr.Error(), "cycle detected", "Error message should mention cycle") assert.NotNil(t, cycleComponent, "Cycle check should return the component that is part of the cycle") // Verify both foo and bar are in the discovered components componentPaths := components.Paths() assert.Contains(t, componentPaths, fooDir, "Foo should be discovered") assert.Contains(t, componentPaths, barDir, "Bar should be discovered") } // TestDiscovery_CycleDetectionWithDisabledDependency tests that disabled dependencies don't create cycles. func TestDiscovery_CycleDetectionWithDisabledDependency(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) fooDir := filepath.Join(tmpDir, "foo") barDir := filepath.Join(tmpDir, "bar") testDirs := []string{fooDir, barDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create terragrunt.hcl files where one dependency is disabled testFiles := map[string]string{ filepath.Join(fooDir, "terragrunt.hcl"): ` dependency "bar" { config_path = "../bar" enabled = false } `, filepath.Join(barDir, "terragrunt.hcl"): ` dependency "foo" { config_path = "../foo" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err, "Discovery should complete") // Verify that a cycle is NOT detected because one dependency is disabled _, cycleErr := components.CycleCheck() require.NoError(t, cycleErr, "Cycle check should not detect a cycle when dependency is disabled") // Verify both foo and bar are in the discovered components componentPaths := components.Paths() assert.Contains(t, componentPaths, fooDir, "Foo should be discovered") assert.Contains(t, componentPaths, barDir, "Bar should be discovered") } // TestDiscovery_WithParseExclude tests that WithParseExclude enables parsing of exclude blocks // and that the exclude configurations are accessible on the discovered units. func TestDiscovery_WithParseExclude(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure testDirs := []string{ "unit1", "unit2", "unit3", } for _, dir := range testDirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) require.NoError(t, err) } // Create test files with exclude configurations testFiles := map[string]string{ "unit1/terragrunt.hcl": ` exclude { if = true actions = ["plan"] }`, "unit2/terragrunt.hcl": ` exclude { if = true actions = ["apply"] }`, "unit3/terragrunt.hcl": "", } for path, content := range testFiles { err := os.WriteFile(filepath.Join(tmpDir, path), []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // WithParseExclude sets requiresParse=true which triggers the parse phase, // allowing exclude blocks to be parsed and accessible on the units. d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithParseExclude() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Verify we found all configurations assert.Len(t, components, 3) // Helper to find unit by path findUnit := func(path string) *component.Unit { for _, c := range components { if filepath.Base(c.Path()) == path { if unit, ok := c.(*component.Unit); ok { return unit } } } return nil } // Verify exclude configurations were parsed correctly unit1 := findUnit("unit1") require.NotNil(t, unit1) require.NotNil(t, unit1.Config(), "unit1 should have a parsed config") require.NotNil(t, unit1.Config().Exclude, "unit1 should have an exclude block") assert.Contains(t, unit1.Config().Exclude.Actions, "plan", "unit1 exclude should contain 'plan' action") unit2 := findUnit("unit2") require.NotNil(t, unit2) require.NotNil(t, unit2.Config(), "unit2 should have a parsed config") require.NotNil(t, unit2.Config().Exclude, "unit2 should have an exclude block") assert.Contains(t, unit2.Config().Exclude.Actions, "apply", "unit2 exclude should contain 'apply' action") unit3 := findUnit("unit3") require.NotNil(t, unit3) // unit3 has an empty config, so Config() may be nil or Exclude may be nil if unit3.Config() != nil { assert.Nil(t, unit3.Config().Exclude, "unit3 should not have an exclude block") } } // TestDiscovery_WithCustomConfigFilenames tests discovery with custom config filenames. func TestDiscovery_WithCustomConfigFilenames(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create units with custom config filenames unit1Dir := filepath.Join(tmpDir, "unit1") unit2Dir := filepath.Join(tmpDir, "unit2") require.NoError(t, os.MkdirAll(unit1Dir, 0755)) require.NoError(t, os.MkdirAll(unit2Dir, 0755)) // Standard terragrunt.hcl in unit1 require.NoError(t, os.WriteFile(filepath.Join(unit1Dir, "terragrunt.hcl"), []byte(""), 0644)) // Custom config in unit2 require.NoError(t, os.WriteFile(filepath.Join(unit2Dir, "custom.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() t.Run("discover only custom config filename", func(t *testing.T) { t.Parallel() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithConfigFilenames([]string{"custom.hcl"}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() assert.Len(t, units, 1) assert.ElementsMatch(t, []string{unit2Dir}, units) }) t.Run("discover both standard and custom config filenames", func(t *testing.T) { t.Parallel() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithConfigFilenames([]string{"terragrunt.hcl", "custom.hcl"}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() assert.Len(t, units, 2) assert.ElementsMatch(t, []string{unit1Dir, unit2Dir}, units) }) } // TestDiscovery_WithReadFiles tests that reading field is populated when using reading filters. // The implementation requires a filter that triggers parsing to populate the reading field. func TestDiscovery_WithReadFiles(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) // Create shared files that will be read sharedHCL := filepath.Join(tmpDir, "shared.hcl") sharedTFVars := filepath.Join(tmpDir, "shared.tfvars") require.NoError(t, os.WriteFile(sharedHCL, []byte(` locals { common_value = "test" } `), 0644)) require.NoError(t, os.WriteFile(sharedTFVars, []byte(` test_var = "value" `), 0644)) // Create terragrunt config that reads both files terragruntConfig := filepath.Join(appDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(terragruntConfig, []byte(` locals { shared_config = read_terragrunt_config("../shared.hcl") tfvars = read_tfvars_file("../shared.tfvars") } `), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use a reading filter to trigger parsing and populate the reading field filters, err := filter.ParseFilterQueries(l, []string{"reading=shared.hcl"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithReadFiles() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Find the app component var appComponent *component.Unit for _, c := range components { if c.Path() == appDir { if unit, ok := c.(*component.Unit); ok { appComponent = unit } break } } require.NotNil(t, appComponent, "app component should be discovered") require.NotNil(t, appComponent.Reading(), "Reading field should be initialized") // Verify Reading field contains the files that were read require.NotEmpty(t, appComponent.Reading(), "should have read files") assert.Contains(t, appComponent.Reading(), sharedHCL, "should contain shared.hcl") } // TestDiscovery_WithStackConfigParsing tests that stack files are discovered but not parsed as unit configs. func TestDiscovery_WithStackConfigParsing(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) stackDir := filepath.Join(tmpDir, "stack") unitDir := filepath.Join(tmpDir, "unit") testDirs := []string{stackDir, unitDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create a stack file with unit blocks stackContent := ` unit "unit_a" { source = "${get_repo_root()}/unit_a" path = "unit_a" } unit "unit_b" { source = "${get_repo_root()}/unit_b" path = "unit_b" } ` // Create a unit file with valid unit configuration unitContent := ` terraform { source = "." } inputs = { test = "value" } ` testFiles := map[string]string{ filepath.Join(stackDir, "terragrunt.stack.hcl"): stackContent, filepath.Join(unitDir, "terragrunt.hcl"): unitContent, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Verify that both stack and unit configurations are discovered units := components.Filter(component.UnitKind) stacks := components.Filter(component.StackKind) assert.Len(t, units, 1) assert.Len(t, stacks, 1) // Verify that stack configuration is not parsed (Config should be nil) stackComp := stacks[0] stack, ok := stackComp.(*component.Stack) require.True(t, ok, "should be a Stack") assert.Nil(t, stack.Config(), "Stack configuration should not be parsed") // Verify that unit configuration is parsed (Config should not be nil) unitComp := units[0] unit, ok := unitComp.(*component.Unit) require.True(t, ok, "should be a Unit") assert.NotNil(t, unit.Config(), "Unit configuration should be parsed") } // TestDiscovery_IncludeExcludeFilterSemantics tests include/exclude filter behavior. func TestDiscovery_IncludeExcludeFilterSemantics(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) unit1Dir := filepath.Join(tmpDir, "unit1") unit2Dir := filepath.Join(tmpDir, "unit2") unit3Dir := filepath.Join(tmpDir, "unit3") for _, d := range []string{unit1Dir, unit2Dir, unit3Dir} { require.NoError(t, os.MkdirAll(d, 0755)) } for _, f := range []string{ filepath.Join(unit1Dir, "terragrunt.hcl"), filepath.Join(unit2Dir, "terragrunt.hcl"), filepath.Join(unit3Dir, "terragrunt.hcl"), } { require.NoError(t, os.WriteFile(f, []byte(""), 0644)) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() tests := []struct { name string filters []string want []string }{ { name: "include by default (no filters)", filters: []string{}, want: []string{unit1Dir, unit2Dir, unit3Dir}, }, { name: "exclude by default when positive filter", filters: []string{"unit1"}, want: []string{unit1Dir}, }, { name: "include by default with only negative filter", filters: []string{"!unit2"}, want: []string{unit1Dir, unit3Dir}, }, { name: "exclude by default with positive and negative filters", filters: []string{"unit1", "!unit2"}, want: []string{unit1Dir}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filters) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.ElementsMatch(t, tt.want, components.Filter(component.UnitKind).Paths()) }) } } // TestDiscovery_HiddenIncludedByIncludeDirs tests hidden directories are included when explicitly filtered. func TestDiscovery_HiddenIncludedByIncludeDirs(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) hiddenUnitDir := filepath.Join(tmpDir, ".hidden", "hunit") require.NoError(t, os.MkdirAll(hiddenUnitDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(hiddenUnitDir, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, []string{"./.hidden/**"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.ElementsMatch(t, []string{hiddenUnitDir}, components.Filter(component.UnitKind).Paths()) } // TestDiscovery_ExternalDependencies tests that external dependencies are correctly identified. func TestDiscovery_ExternalDependencies(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) internalDir := filepath.Join(tmpDir, "internal") externalDir := filepath.Join(tmpDir, "external") appDir := filepath.Join(internalDir, "app") dbDir := filepath.Join(internalDir, "db") vpcDir := filepath.Join(internalDir, "vpc") extApp := filepath.Join(externalDir, "app") for _, d := range []string{appDir, dbDir, vpcDir, extApp} { require.NoError(t, os.MkdirAll(d, 0755)) } require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(` dependency "db" { config_path = "../db" } dependency "external" { config_path = "../../external/app" } `), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(` dependency "vpc" { config_path = "../vpc" } `), 0644)) require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(""), 0644)) require.NoError(t, os.WriteFile(filepath.Join(extApp, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: internalDir, RootWorkingDir: internalDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(internalDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Find app config and assert it has external dependency var appCfg *component.Unit for _, c := range components { if c.Path() == appDir { if unit, ok := c.(*component.Unit); ok { appCfg = unit } break } } require.NotNil(t, appCfg) depPaths := appCfg.Dependencies().Paths() assert.Contains(t, depPaths, dbDir) assert.Contains(t, depPaths, extApp) // Verify external dependency is marked as external for _, dep := range appCfg.Dependencies() { if dep.Path() == extApp { assert.True(t, dep.External(), "external app should be marked as external") } } } // TestDiscovery_BreakCycles tests that WithBreakCycles removes cyclic components. func TestDiscovery_BreakCycles(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) fooDir := filepath.Join(tmpDir, "foo") barDir := filepath.Join(tmpDir, "bar") testDirs := []string{fooDir, barDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create terragrunt.hcl files with mutual dependencies (cycle) testFiles := map[string]string{ filepath.Join(fooDir, "terragrunt.hcl"): ` dependency "bar" { config_path = "../bar" } `, filepath.Join(barDir, "terragrunt.hcl"): ` dependency "foo" { config_path = "../foo" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithBreakCycles() components, err := d.Discover(ctx, l, opts) require.NoError(t, err, "Discovery should complete with break cycles enabled") // With break cycles enabled, the cycle should be resolved (one component removed) _, cycleErr := components.CycleCheck() require.NoError(t, cycleErr, "Cycle check should not detect a cycle after breaking") } // TestDiscovery_WithNumWorkers tests that the worker count can be configured. func TestDiscovery_WithNumWorkers(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create a few test units for i := range 5 { dir := filepath.Join(tmpDir, "unit"+string(rune('a'+i))) require.NoError(t, os.MkdirAll(dir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "terragrunt.hcl"), []byte(""), 0644)) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithNumWorkers(2) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 5) } // TestDiscovery_WithMaxDependencyDepth tests dependency depth limiting. func TestDiscovery_WithMaxDependencyDepth(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create chain: a -> b -> c -> d aDir := filepath.Join(tmpDir, "a") bDir := filepath.Join(tmpDir, "b") cDir := filepath.Join(tmpDir, "c") dDir := filepath.Join(tmpDir, "d") for _, dir := range []string{aDir, bDir, cDir, dDir} { require.NoError(t, os.MkdirAll(dir, 0755)) } testFiles := map[string]string{ filepath.Join(aDir, "terragrunt.hcl"): ` dependency "b" { config_path = "../b" } `, filepath.Join(bDir, "terragrunt.hcl"): ` dependency "c" { config_path = "../c" } `, filepath.Join(cDir, "terragrunt.hcl"): ` dependency "d" { config_path = "../d" } `, filepath.Join(dDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() t.Run("full depth discovers all", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"a..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithMaxDependencyDepth(100) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) paths := components.Paths() assert.Contains(t, paths, aDir) assert.Contains(t, paths, bDir) assert.Contains(t, paths, cDir) assert.Contains(t, paths, dDir) }) t.Run("limited depth", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"a..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithMaxDependencyDepth(1) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) paths := components.Paths() assert.Contains(t, paths, aDir, "a should always be included") // With depth 1, we should get at least a and b assert.Contains(t, paths, bDir, "b should be included with depth 1") }) } // TestDiscovery_SuppressParseErrors tests that parse errors can be suppressed. func TestDiscovery_SuppressParseErrors(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) validDir := filepath.Join(tmpDir, "valid") invalidDir := filepath.Join(tmpDir, "invalid") require.NoError(t, os.MkdirAll(validDir, 0755)) require.NoError(t, os.MkdirAll(invalidDir, 0755)) // Valid config require.NoError(t, os.WriteFile(filepath.Join(validDir, "terragrunt.hcl"), []byte(""), 0644)) // Invalid config (should cause parse error) require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "terragrunt.hcl"), []byte(` terraform { source = undefined_function() } `), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithParseExclude(). WithSuppressParseErrors() components, err := d.Discover(ctx, l, opts) require.NoError(t, err, "Discovery should succeed with suppressed parse errors") // Valid config should be discovered paths := components.Paths() assert.Contains(t, paths, validDir) } // TestDiscovery_ExcludeDependencies tests that ExcludeDependencies only takes effect // when the dependent unit's exclude condition (If) is true. func TestDiscovery_ExcludeDependencies(t *testing.T) { t.Parallel() tests := []struct { name string excludeIf string dependentExcluded bool dependencyExcluded bool }{ { name: "exclude_dependencies with if=false", excludeIf: "false", dependentExcluded: false, dependencyExcluded: false, }, { name: "exclude_dependencies with if=true", excludeIf: "true", dependentExcluded: true, dependencyExcluded: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) dependentDir := filepath.Join(tmpDir, "dependent") dependencyDir := filepath.Join(tmpDir, "dependency") require.NoError(t, os.MkdirAll(dependentDir, 0755)) require.NoError(t, os.MkdirAll(dependencyDir, 0755)) dependentHCL := ` exclude { if = ` + tt.excludeIf + ` actions = ["all"] exclude_dependencies = true } dependency "dependency" { config_path = "../dependency" } ` require.NoError(t, os.WriteFile(filepath.Join(dependentDir, "terragrunt.hcl"), []byte(dependentHCL), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dependencyDir, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, TerraformCommand: "plan", } ctx := t.Context() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithParseExclude(). WithRelationships() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) var dependentUnit, dependencyUnit *component.Unit for _, c := range components { unit, ok := c.(*component.Unit) if !ok { continue } switch c.Path() { case dependentDir: dependentUnit = unit case dependencyDir: dependencyUnit = unit } } require.NotNil(t, dependentUnit, "dependent unit should be discovered") require.NotNil(t, dependencyUnit, "dependency unit should be discovered") assert.Equal(t, tt.dependentExcluded, dependentUnit.Excluded(), "dependent excluded state") assert.Equal(t, tt.dependencyExcluded, dependencyUnit.Excluded(), "dependency excluded state") }) } } // TestDiscovery_OriginalTerragruntConfigPath tests that get_original_terragrunt_dir() returns the // correct directory during parsing. This verifies that phase_parse.go correctly sets // OriginalTerragruntConfigPath when parsing units. func TestDiscovery_OriginalTerragruntConfigPath(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") dbDir := filepath.Join(tmpDir, "db") require.NoError(t, os.MkdirAll(appDir, 0755)) require.NoError(t, os.MkdirAll(dbDir, 0755)) // Create a config that uses get_original_terragrunt_dir() in the terraform source // This function relies on OriginalTerragruntConfigPath being set correctly require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(` terraform { source = "${get_original_terragrunt_dir()}/module" } dependency "db" { config_path = "../db" } `), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, // Start with a different config path to simulate the scenario where opts is cloned TerragruntConfigPath: tmpDir, OriginalTerragruntConfigPath: tmpDir, } ctx := t.Context() // Use a dependency traversal filter (app...) to trigger parsing filters, err := filter.ParseFilterQueries(l, []string{"app..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Find the app component var appComponent *component.Unit for _, c := range components { if c.Path() == appDir { if unit, ok := c.(*component.Unit); ok { appComponent = unit } break } } require.NotNil(t, appComponent, "app component should be discovered") require.NotNil(t, appComponent.Config(), "app config should be parsed") require.NotNil(t, appComponent.Config().Terraform, "terraform block should be parsed") require.NotNil(t, appComponent.Config().Terraform.Source, "terraform source should be parsed") // The key test: verify that get_original_terragrunt_dir() returned the correct directory // It should resolve to the app unit's directory, not the initial opts value (tmpDir) expectedSource := filepath.Join(appDir, "module") assert.Equal(t, expectedSource, *appComponent.Config().Terraform.Source, "terraform source should use the correct unit directory from get_original_terragrunt_dir()") } ================================================ FILE: internal/discovery/discovery_test.go ================================================ package discovery_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCandidacyClassifier_Analyze(t *testing.T) { t.Parallel() tests := []struct { name string filterStrings []string expectHasPositive bool expectHasParseRequired bool expectHasGraphFilters bool expectGraphExprCount int }{ { name: "empty filters", filterStrings: []string{}, expectHasPositive: false, }, { name: "simple path filter", filterStrings: []string{"./foo"}, expectHasPositive: true, }, { name: "negated path filter only", filterStrings: []string{"!./foo"}, expectHasPositive: false, }, { name: "path filter with negation", filterStrings: []string{"./foo", "!./bar"}, expectHasPositive: true, }, { name: "reading attribute filter", filterStrings: []string{"reading=config/*"}, expectHasPositive: true, expectHasParseRequired: true, }, { name: "dependency graph filter", filterStrings: []string{"./foo..."}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "dependent graph filter", filterStrings: []string{"..../foo"}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "exclude target graph filter", filterStrings: []string{"^{./foo}..."}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "multiple graph filters", filterStrings: []string{"./foo...", "..../bar"}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 2, }, { name: "name attribute filter", filterStrings: []string{"name=my-app"}, expectHasPositive: true, }, { name: "type attribute filter", filterStrings: []string{"type=unit"}, expectHasPositive: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() filters, err := filter.ParseFilterQueries(l, tt.filterStrings) require.NoError(t, err) classifier := filter.NewClassifier(filters) assert.Equal(t, tt.expectHasPositive, classifier.HasPositiveFilters(), "HasPositiveFilters mismatch") assert.Equal(t, tt.expectHasParseRequired, classifier.HasParseRequiredFilters(), "HasParseRequiredFilters mismatch") assert.Equal(t, tt.expectHasGraphFilters, classifier.HasGraphFilters(), "HasGraphFilters mismatch") if tt.expectGraphExprCount > 0 { assert.Len(t, classifier.GraphExpressions(), tt.expectGraphExprCount, "GraphExpressions count mismatch") } }) } } func TestCandidacyClassifier_ClassifyComponent(t *testing.T) { t.Parallel() tests := []struct { name string componentPath string workingDir string filterStrings []string expectStatus filter.ClassificationStatus expectReason filter.CandidacyReason expectIndex int }{ { name: "no filters - include by default", filterStrings: []string{}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, expectIndex: -1, }, { name: "matching path filter", filterStrings: []string{"./foo"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, expectIndex: -1, }, { name: "non-matching path filter - exclude by default", filterStrings: []string{"./bar"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusExcluded, expectReason: filter.CandidacyReasonNone, expectIndex: -1, }, { name: "negated filter only - exclude component", filterStrings: []string{"!./foo"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusExcluded, expectReason: filter.CandidacyReasonNone, expectIndex: -1, }, { name: "negated filter only - include other", filterStrings: []string{"!./foo"}, componentPath: "/project/bar", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, expectIndex: -1, }, { name: "graph expression target - candidate", filterStrings: []string{"./foo..."}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusCandidate, expectReason: filter.CandidacyReasonGraphTarget, expectIndex: 0, }, { name: "parse required filter - candidate", filterStrings: []string{"reading=config/*"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusCandidate, expectReason: filter.CandidacyReasonRequiresParse, expectIndex: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() filters, err := filter.ParseFilterQueries(l, tt.filterStrings) require.NoError(t, err) classifier := filter.NewClassifier(filters) // Create a test component c := component.NewUnit(tt.componentPath) c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tt.workingDir, }) ctx := filter.ClassificationContext{} status, reason, index := classifier.Classify(c, ctx) assert.Equal(t, tt.expectStatus, status, "status mismatch") assert.Equal(t, tt.expectReason, reason, "reason mismatch") assert.Equal(t, tt.expectIndex, index, "index mismatch") }) } } func TestDiscovery_SimpleFilesystem(t *testing.T) { t.Parallel() // Create a temporary directory structure tmpDir := t.TempDir() // Create some terragrunt.hcl files dirs := []string{"foo", "bar", "baz"} for _, dir := range dirs { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) require.NoError(t, os.WriteFile( filepath.Join(dirPath, "terragrunt.hcl"), []byte("# Test config\n"), 0644, )) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // Test: discover all components d := discovery.NewDiscovery(tmpDir).WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tmpDir, }) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 3, "should discover 3 components") } func TestDiscovery_WithPathFilter(t *testing.T) { t.Parallel() // Create a temporary directory structure tmpDir := t.TempDir() // Create some terragrunt.hcl files dirs := []string{"apps/foo", "apps/bar", "infra/baz"} for _, dir := range dirs { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) require.NoError(t, os.WriteFile( filepath.Join(dirPath, "terragrunt.hcl"), []byte("# Test config\n"), 0644, )) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // Test: filter to apps/* only filters, err := filter.ParseFilterQueries(l, []string{"./apps/*"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tmpDir, }). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 2, "should discover 2 components in apps/") } func TestDiscovery_WithNegatedFilter(t *testing.T) { t.Parallel() // Create a temporary directory structure tmpDir := t.TempDir() // Create some terragrunt.hcl files dirs := []string{"foo", "bar", "baz"} for _, dir := range dirs { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) require.NoError(t, os.WriteFile( filepath.Join(dirPath, "terragrunt.hcl"), []byte("# Test config\n"), 0644, )) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // Test: exclude ./bar filters, err := filter.ParseFilterQueries(l, []string{"!./bar"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tmpDir, }). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 2, "should discover 2 components (excluding bar)") // Verify bar is not in results for _, c := range components { assert.NotContains(t, c.Path(), "bar", "bar should be excluded") } } func TestDiscovery_CombinedFilters(t *testing.T) { t.Parallel() // Create a temporary directory structure tmpDir := t.TempDir() // Create some terragrunt.hcl files dirs := []string{"apps/foo", "apps/bar", "apps/baz", "infra/db"} for _, dir := range dirs { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) require.NoError(t, os.WriteFile( filepath.Join(dirPath, "terragrunt.hcl"), []byte("# Test config\n"), 0644, )) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // Test: ./apps/* but not ./apps/baz filters, err := filter.ParseFilterQueries(l, []string{"./apps/*", "!./apps/baz"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tmpDir, }). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 2, "should discover 2 components (apps/* minus baz)") // Verify baz is not in results for _, c := range components { assert.NotContains(t, c.Path(), "baz", "baz should be excluded") } } func TestPhaseKind_String(t *testing.T) { t.Parallel() tests := []struct { expected string kind discovery.PhaseKind }{ {expected: "filesystem", kind: discovery.PhaseFilesystem}, {expected: "worktree", kind: discovery.PhaseWorktree}, {expected: "parse", kind: discovery.PhaseParse}, {expected: "graph", kind: discovery.PhaseGraph}, {expected: "relationship", kind: discovery.PhaseRelationship}, {expected: "final", kind: discovery.PhaseFinal}, {expected: "unknown", kind: discovery.PhaseKind(999)}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.kind.String()) }) } } func TestDiscoveryStatus_String(t *testing.T) { t.Parallel() tests := []struct { expected string status filter.ClassificationStatus }{ {expected: "discovered", status: filter.StatusDiscovered}, {expected: "candidate", status: filter.StatusCandidate}, {expected: "excluded", status: filter.StatusExcluded}, {expected: "unknown", status: filter.ClassificationStatus(999)}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.status.String()) }) } } func TestCandidacyReason_String(t *testing.T) { t.Parallel() tests := []struct { expected string reason filter.CandidacyReason }{ {expected: "none", reason: filter.CandidacyReasonNone}, {expected: "graph-target", reason: filter.CandidacyReasonGraphTarget}, {expected: "requires-parse", reason: filter.CandidacyReasonRequiresParse}, {expected: "unknown", reason: filter.CandidacyReason(999)}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.reason.String()) }) } } // TestDiscovery_PopulatesReadingField verifies that the Reading field is populated // with files read during parsing via read_terragrunt_config() and read_tfvars_file(). func TestDiscovery_PopulatesReadingField(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) // Create shared files that will be read sharedHCL := filepath.Join(tmpDir, "shared.hcl") sharedTFVars := filepath.Join(tmpDir, "shared.tfvars") require.NoError(t, os.WriteFile(sharedHCL, []byte(` locals { common_value = "test" } `), 0644)) require.NoError(t, os.WriteFile(sharedTFVars, []byte(` test_var = "value" `), 0644)) // Create terragrunt config that reads both files terragruntConfig := filepath.Join(appDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(terragruntConfig, []byte(` locals { shared_config = read_terragrunt_config("../shared.hcl") tfvars = read_tfvars_file("../shared.tfvars") } `), 0644)) opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } l := logger.CreateLogger() ctx := t.Context() // Discover components with ReadFiles enabled to populate Reading field d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithReadFiles() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Find the app component var appComponent *component.Unit for _, c := range components { if c.Path() == appDir { if unit, ok := c.(*component.Unit); ok { appComponent = unit } break } } require.NotNil(t, appComponent, "app component should be discovered") require.NotNil(t, appComponent.Reading(), "Reading field should be initialized") // Verify Reading field contains the files that were read require.NotEmpty(t, appComponent.Reading(), "should have read files") assert.Contains(t, appComponent.Reading(), sharedHCL, "should contain shared.hcl") assert.Contains(t, appComponent.Reading(), sharedTFVars, "should contain shared.tfvars") } func TestDiscovery_BothHclAndStackFileInSameDir(t *testing.T) { t.Parallel() tmpDir := t.TempDir() subDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(subDir, 0755)) require.NoError(t, os.WriteFile( filepath.Join(subDir, "terragrunt.hcl"), []byte("# empty unit config\n"), 0644, )) require.NoError(t, os.WriteFile( filepath.Join(subDir, "terragrunt.stack.hcl"), []byte("# empty stack config\n"), 0644, )) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) _, err := d.Discover(t.Context(), l, opts) require.Error(t, err) var coexistErr discovery.CoexistenceError require.ErrorAs(t, err, &coexistErr) assert.Equal(t, subDir, coexistErr.ComponentPath) } // TestDiscovery_SingleUnitNoDuplicateError verifies that a directory with only // a single config file does not trigger a coexistence error. func TestDiscovery_SingleUnitNoDuplicateError(t *testing.T) { t.Parallel() tmpDir := t.TempDir() subDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(subDir, 0755)) require.NoError(t, os.WriteFile( filepath.Join(subDir, "terragrunt.hcl"), []byte("# config\n"), 0644, )) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) components, err := d.Discover(t.Context(), l, opts) require.NoError(t, err) assert.Len(t, components, 1) assert.Equal(t, component.UnitKind, components[0].Kind()) } ================================================ FILE: internal/discovery/doc.go ================================================ // Package discovery provides a channel-based phased discovery architecture for Terragrunt components. // // # Overview // // This package discovers Terragrunt components (units and stacks) across a directory tree // using a multi-phase pipeline. // // Each phase communicates via two output channels: // - discovered: Components definitively included in results // - candidates: Components that might be included pending further evaluation // // This dual-channel approach enables lazy evaluation. Components are only parsed or // graph-traversed when necessary for filter evaluation. // // # Constructors // // The package provides several constructors for different use cases: // // - [NewDiscovery]: Creates a Discovery with sensible defaults including CPU-aware worker // count (scales with runtime.NumCPU, min 4, max 8) and pre-initialized [component.DiscoveryContext]. // This is the recommended constructor for most use cases. // // - [NewForDiscoveryCommand]: Creates a Discovery configured for discovery commands (find/list) // with parse error suppression and cycle breaking enabled. // // - [NewForHCLCommand]: Creates a Discovery for HCL commands (validate/format). // // - [NewForStackGenerate]: Creates a Discovery for stack generate commands. // // # Classification Rules // // The [filter.Classifier] analyzes all filter expressions upfront and classifies // each component into one of three statuses: // // - [StatusDiscovered]: Matches a positive filter (path, attribute, or git expression) // - [StatusCandidate]: Needs further evaluation (graph target, requires parsing, or potential dependent) // - [StatusExcluded]: Only matches negated filters, or positive filters exist but none match // // When no positive filters exist, components are included by default. When positive // filters exist, only matching components are included. // // # Phase Flow // // The discovery process executes in the following phases: // // 1. Filesystem + Worktree Discovery (concurrent) // - [PhaseFilesystem]: Walk directories recursively, classify components via [filter.Classifier] // - [PhaseWorktree]: For Git filters [ref...ref], discover components in temporary worktrees // and detect added/removed/modified components via SHA256 comparison // // 2. Parse Phase (if needed) // - [PhaseParse]: Parse HCL configs for candidates with [CandidacyReasonRequiresParse] // - Re-classify based on parsed attributes (reading, source), promote to discovered or // transition to graph candidate // // 3. Graph Phase (if needed) // - Pre-graph: If dependent filters exist, parse all components and build bidirectional // dependency links for reverse traversal // - [PhaseGraph]: Traverse dependencies (target|N) and/or dependents (...target) based on // [GraphExpressionInfo] configuration // - Supports depth limits and target exclusion (^target) for flexible graph queries // // 4. Relationship Phase (optional) // - [PhaseRelationship]: Build complete dependency graph for execution ordering // - Creates transient components for external dependencies (not in final results) // // 5. Final Phase // - [PhaseFinal]: Merge all discovered, deduplicate by path, apply final filter evaluation // - Cycle detection and removal if configured via [Discovery.WithBreakCycles] // // # Filter Expressions // // The package supports several filter expression types: // // - Path expressions: ./foo, ./foo/**, ./**/vpc (glob patterns) // - Attribute expressions: name=vpc, type=unit, external=true, reading=config/*, source=* // - Graph expressions: vpc (target), vpc|2 (dependencies), ...vpc (dependents), ^vpc|... (exclude target) // - Git expressions: [main...develop] (changes between refs) // - Negated expressions: !./internal (exclusion) // // # Configuration Methods // // Discovery uses a fluent builder pattern. Available configuration methods include: // // - [Discovery.WithFilters]: Set filter queries for component selection // - [Discovery.WithRelationships]: Enable relationship discovery for execution ordering // - [Discovery.WithMaxDependencyDepth]: Set maximum dependency traversal depth (default 1000) // - [Discovery.WithNumWorkers]: Set concurrent worker count (default 4, max 8) // - [Discovery.WithBreakCycles]: Enable cycle detection and removal // - [Discovery.WithNoHidden]: Exclude hidden directories from discovery // - [Discovery.WithRequiresParse]: Force parsing of all Terragrunt configurations // - [Discovery.WithSuppressParseErrors]: Continue discovery despite parse errors // - [Discovery.WithParseExclude]: Parse exclude configurations // - [Discovery.WithParseIncludes]: Parse include configurations // - [Discovery.WithReadFiles]: Parse for file reading information // - [Discovery.WithDiscoveryContext]: Set the discovery context // - [Discovery.WithWorktrees]: Set worktrees for Git-based filters // - [Discovery.WithConfigFilenames]: Set custom config filenames to discover // - [Discovery.WithParserOptions]: Set custom HCL parser options // - [Discovery.WithGitRoot]: Set git root for dependent discovery boundary // - [Discovery.WithGraphTarget]: Set graph target for pruning results // - [Discovery.WithOptions]: Ingest runner options for parser and graph settings // // # Example Usage // // d := NewDiscovery(workingDir). // WithFilters(filters). // WithRelationships(). // WithMaxDependencyDepth(10) // // components, err := d.Discover(ctx, logger, opts) // // # Thread Safety // // All phase communication uses channels with no shared mutable state between phases. // [component.ThreadSafeComponents] provides concurrent component access during graph traversal. // A custom stringSet (RWMutex-based) tracks seen components during traversal. // [errgroup] with configurable worker limits (default 4, max 8) handles concurrent operations. package discovery ================================================ FILE: internal/discovery/errors.go ================================================ package discovery import ( "fmt" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" ) // GitFilterCommandError represents an error that occurs when attempting to use // Git-based filtering with an unsupported command. type GitFilterCommandError struct { Cmd string Args []string } func (e GitFilterCommandError) Error() string { command := strings.TrimSpace( strings.Join( append( []string{e.Cmd}, e.Args..., ), " ", ), ) return fmt.Sprintf( "Git-based filtering is not supported with the command '%s'. "+ "Git-based filtering can only be used with 'plan', 'apply', "+ "or discovery commands (like 'find' or 'list') that don't require additional arguments.", command, ) } // NewGitFilterCommandError creates a new GitFilterCommandError with the given command and arguments. func NewGitFilterCommandError(cmd string, args []string) error { return errors.New(GitFilterCommandError{ Cmd: cmd, Args: args, }) } // MissingDiscoveryContextError represents an error that occurs when a component // is missing its discovery context during dependency discovery. This indicates // a bug in Terragrunt. type MissingDiscoveryContextError struct { ComponentPath string } func (e MissingDiscoveryContextError) Error() string { return fmt.Sprintf( "Component at path '%s' is missing its discovery context during dependency discovery. "+ "This is a bug in Terragrunt. "+ "Please open a bug report at https://github.com/gruntwork-io/terragrunt/issues "+ "with details about how you encountered this error.", e.ComponentPath, ) } // NewMissingDiscoveryContextError creates a new MissingDiscoveryContextError for the given component path. func NewMissingDiscoveryContextError(componentPath string) error { return errors.New(MissingDiscoveryContextError{ ComponentPath: componentPath, }) } // MissingWorkingDirectoryError represents an error that occurs when a component's // discovery context is missing its working directory during dependency discovery. // This indicates a bug in Terragrunt. type MissingWorkingDirectoryError struct { ComponentPath string } func (e MissingWorkingDirectoryError) Error() string { return fmt.Sprintf( "Component at path '%s' has a discovery context but is missing its working directory during dependency discovery. "+ "This is a bug in Terragrunt. "+ "Please open a bug report at https://github.com/gruntwork-io/terragrunt/issues "+ "with details about how you encountered this error.", e.ComponentPath, ) } // NewMissingWorkingDirectoryError creates a new MissingWorkingDirectoryError for the given component path. func NewMissingWorkingDirectoryError(componentPath string) error { return errors.New(MissingWorkingDirectoryError{ ComponentPath: componentPath, }) } // ClassificationError represents an error during component classification. type ClassificationError struct { ComponentPath string Reason string } func (e ClassificationError) Error() string { return fmt.Sprintf( "Failed to classify component at '%s': %s", e.ComponentPath, e.Reason, ) } // NewClassificationError creates a new ClassificationError. func NewClassificationError(componentPath, reason string) error { return errors.New(ClassificationError{ ComponentPath: componentPath, Reason: reason, }) } // CoexistenceError represents an error when a directory contains both // a unit configuration file and a stack configuration file. type CoexistenceError struct { ComponentPath string UnitConfigFile string StackConfigFile string } func (e CoexistenceError) Error() string { return fmt.Sprintf( "Component %q contains both configuration files %s and %s. "+ "A component must be either a unit or a stack, not both.", e.ComponentPath, e.UnitConfigFile, e.StackConfigFile, ) } // NewCoexistenceError creates a new CoexistenceError. func NewCoexistenceError(componentPath, unitConfigFile, stackConfigFile string) error { return errors.New(CoexistenceError{ ComponentPath: componentPath, UnitConfigFile: unitConfigFile, StackConfigFile: stackConfigFile, }) } ================================================ FILE: internal/discovery/filter_test.go ================================================ package discovery_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestDiscovery_GraphExpressionFilters tests graph expression filter functionality. func TestDiscovery_GraphExpressionFilters(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create test files with dependencies testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() tests := []struct { name string filterQueries []string wantUnits []string }{ { name: "dependency discovery - app...", filterQueries: []string{"app..."}, wantUnits: []string{appDir, dbDir, vpcDir}, }, { name: "braced path with dependencies - {./app}...", filterQueries: []string{"{./app}..."}, wantUnits: []string{appDir, dbDir, vpcDir}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterQueries) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name) }) } } // TestDiscovery_GraphExpressionFilters_ComplexGraph tests graph expressions with a more complex graph. func TestDiscovery_GraphExpressionFilters_ComplexGraph(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create complex graph: vpc -> [db, cache] -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") cacheDir := filepath.Join(tmpDir, "cache") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, cacheDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } dependency "cache" { config_path = "../cache" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(cacheDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() t.Run("dependency traversal from app finds all dependencies", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"app..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{appDir, dbDir, cacheDir, vpcDir}, units) }) } // TestDiscovery_GraphExpressionFilters_OnlyMatchingComponentsTriggerDiscovery tests selective graph discovery. func TestDiscovery_GraphExpressionFilters_OnlyMatchingComponentsTriggerDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create components: app depends on db, but there's also an unrelated component appDir := filepath.Join(tmpDir, "app") dbDir := filepath.Join(tmpDir, "db") unrelatedDir := filepath.Join(tmpDir, "unrelated") testDirs := []string{appDir, dbDir, unrelatedDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ``, filepath.Join(unrelatedDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() t.Run("graph expression only discovers dependencies of matching component", func(t *testing.T) { t.Parallel() // Filter for app and its dependencies - unrelated should not be included filters, err := filter.ParseFilterQueries(l, []string{"app..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() // Should include app and db, but NOT unrelated assert.ElementsMatch(t, []string{appDir, dbDir}, units) assert.NotContains(t, units, unrelatedDir) }) } // TestDiscovery_GraphExpressionFilters_FiltersAppliedAfterDiscovery tests additional filters after graph discovery. func TestDiscovery_GraphExpressionFilters_FiltersAppliedAfterDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create dependency graph: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() t.Run("additional filters applied after graph discovery", func(t *testing.T) { t.Parallel() // Graph expression discovers app and its dependencies, then additional filter excludes vpc filters, err := filter.ParseFilterQueries(l, []string{"app...", "!vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() // Should include app and db (from graph), but exclude vpc (from filter) assert.ElementsMatch(t, []string{appDir, dbDir}, units) assert.NotContains(t, units, vpcDir) }) } // TestDiscovery_ReadingAttributeFilters tests reading attribute filter functionality. func TestDiscovery_ReadingAttributeFilters(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create shared configuration files sharedHCL := filepath.Join(tmpDir, "shared.hcl") sharedTFVars := filepath.Join(tmpDir, "shared.tfvars") commonVars := filepath.Join(tmpDir, "common", "variables.hcl") dbConfig := filepath.Join(tmpDir, "database.yaml") require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "common"), 0755)) require.NoError(t, os.WriteFile(sharedHCL, []byte(` locals { common_value = "test" } `), 0644)) require.NoError(t, os.WriteFile(sharedTFVars, []byte(` test_var = "value" another_var = "test" `), 0644)) require.NoError(t, os.WriteFile(commonVars, []byte(` locals { vpc_cidr = "10.0.0.0/16" } `), 0644)) require.NoError(t, os.WriteFile(dbConfig, []byte(` locals { db_host = "localhost" db_port = 5432 } `), 0644)) // Create test components with different file reads frontendDir := filepath.Join(tmpDir, "apps", "frontend") backendDir := filepath.Join(tmpDir, "apps", "backend") legacyDir := filepath.Join(tmpDir, "apps", "legacy") dbDir := filepath.Join(tmpDir, "libs", "db") cacheDir := filepath.Join(tmpDir, "libs", "cache") testDirs := []string{frontendDir, backendDir, legacyDir, dbDir, cacheDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create test files with different file reading patterns // Note: Only read_terragrunt_config and read_tfvars_file populate the Reading slice testFiles := map[string]string{ filepath.Join(frontendDir, "terragrunt.hcl"): ` locals { shared = read_terragrunt_config("../../shared.hcl") vars = read_tfvars_file("../../shared.tfvars") } `, filepath.Join(backendDir, "terragrunt.hcl"): ` locals { shared = read_terragrunt_config("../../shared.hcl") common = read_terragrunt_config("../../common/variables.hcl") } `, filepath.Join(legacyDir, "terragrunt.hcl"): ` locals { # Uses a file that will be tracked db_config = read_terragrunt_config("../../database.yaml") } `, filepath.Join(dbDir, "terragrunt.hcl"): ` locals { common = read_terragrunt_config("../../common/variables.hcl") db_config = read_terragrunt_config("../../database.yaml") } `, filepath.Join(cacheDir, "terragrunt.hcl"): ` # No file reads `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() tests := []struct { name string filterQueries []string wantUnits []string }{ { name: "filter by exact file - shared.hcl", filterQueries: []string{"reading=shared.hcl"}, wantUnits: []string{frontendDir, backendDir}, }, { name: "filter by exact file - database.yaml", filterQueries: []string{"reading=database.yaml"}, wantUnits: []string{legacyDir, dbDir}, }, { name: "filter by glob - shared prefix", filterQueries: []string{"reading=shared*"}, wantUnits: []string{frontendDir, backendDir}, }, { name: "filter by exact nested path", filterQueries: []string{"reading=common/variables.hcl"}, wantUnits: []string{backendDir, dbDir}, }, { name: "negation - exclude components reading shared.hcl", filterQueries: []string{"!reading=shared.hcl"}, wantUnits: []string{legacyDir, dbDir, cacheDir}, }, { name: "negation with glob - exclude components reading database.yaml", filterQueries: []string{"!reading=database.yaml"}, wantUnits: []string{frontendDir, backendDir, cacheDir}, }, { name: "intersection - apps directory reading shared.hcl", filterQueries: []string{"./apps/* | reading=shared.hcl"}, wantUnits: []string{frontendDir, backendDir}, }, { name: "intersection - libs directory with common variables", filterQueries: []string{"./libs/* | reading=common/variables.hcl"}, wantUnits: []string{dbDir}, }, { name: "multiple filters - union semantics", filterQueries: []string{"reading=shared.hcl", "reading=database.yaml"}, wantUnits: []string{frontendDir, backendDir, legacyDir, dbDir}, }, { name: "complex - apps not reading database.yaml", filterQueries: []string{"./apps/* | !reading=database.yaml"}, wantUnits: []string{frontendDir, backendDir}, }, { name: "no matches - nonexistent file", filterQueries: []string{"reading=nonexistent.hcl"}, wantUnits: []string{}, }, { name: "components that don't read any files", filterQueries: []string{"cache"}, wantUnits: []string{cacheDir}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterQueries) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithReadFiles() configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name) }) } } // TestDiscovery_ReadingAttributeFiltersAbsolutePaths tests reading attribute filter with absolute paths. func TestDiscovery_ReadingAttributeFiltersAbsolutePaths(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create a shared file with absolute path sharedFile := filepath.Join(tmpDir, "shared.hcl") require.NoError(t, os.WriteFile(sharedFile, []byte(` locals { value = "test" } `), 0644)) // Create test component appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) terragruntConfig := filepath.Join(appDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(terragruntConfig, []byte(` locals { shared = read_terragrunt_config("../shared.hcl") } `), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Test with absolute path filter filterQueries := []string{"reading=" + sharedFile} filters, err := filter.ParseFilterQueries(l, filterQueries) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithReadFiles() configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should find the app component when filtering by absolute path units := configs.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{appDir}, units, "Should find component by absolute path to read file") } // TestDiscovery_ReadingAttributeFiltersErrorHandling tests error handling for invalid reading filters. func TestDiscovery_ReadingAttributeFiltersErrorHandling(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(""), 0644)) tests := []struct { name string filterQueries []string errorExpectedOnParse bool }{ { name: "invalid glob pattern in reading filter", filterQueries: []string{"reading=[invalid"}, errorExpectedOnParse: true, }, { name: "valid reading filter - no error", filterQueries: []string{"reading=*.hcl"}, errorExpectedOnParse: false, }, } l := logger.CreateLogger() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Parse filter queries _, err := filter.ParseFilterQueries(l, tt.filterQueries) if tt.errorExpectedOnParse { require.Error(t, err, "Expected error for filter: %v", tt.filterQueries) } else { require.NoError(t, err) } }) } } // TestDiscovery_AttributeFilters tests path, name, type, and external attribute filters. func TestDiscovery_AttributeFilters(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure appsDir := filepath.Join(tmpDir, "apps") frontendDir := filepath.Join(appsDir, "frontend") backendDir := filepath.Join(appsDir, "backend") legacyDir := filepath.Join(appsDir, "legacy") libsDir := filepath.Join(tmpDir, "libs") dbDir := filepath.Join(libsDir, "db") cacheDir := filepath.Join(libsDir, "cache") stackDir := filepath.Join(tmpDir, "stack") testDirs := []string{ frontendDir, backendDir, legacyDir, dbDir, cacheDir, stackDir, } for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(frontendDir, "terragrunt.hcl"): ``, filepath.Join(backendDir, "terragrunt.hcl"): ``, filepath.Join(legacyDir, "terragrunt.hcl"): ``, filepath.Join(dbDir, "terragrunt.hcl"): ``, filepath.Join(cacheDir, "terragrunt.hcl"): ``, filepath.Join(stackDir, "terragrunt.stack.hcl"): ` unit "test" { source = "." path = "test" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() tests := []struct { name string filterQueries []string wantUnits []string wantStacks []string }{ { name: "path filter - apps directory", filterQueries: []string{"./apps/*"}, wantUnits: []string{frontendDir, backendDir, legacyDir}, wantStacks: []string{}, }, { name: "path filter with wildcard", filterQueries: []string{"./libs/*"}, wantUnits: []string{dbDir, cacheDir}, wantStacks: []string{}, }, { name: "name filter - specific component", filterQueries: []string{"frontend"}, wantUnits: []string{frontendDir}, wantStacks: []string{}, }, { name: "name filter with equals", filterQueries: []string{"name=backend"}, wantUnits: []string{backendDir}, wantStacks: []string{}, }, { name: "type filter - units only", filterQueries: []string{"type=unit"}, wantUnits: []string{frontendDir, backendDir, legacyDir, dbDir, cacheDir}, wantStacks: []string{}, }, { name: "type filter - stacks only", filterQueries: []string{"type=stack"}, wantUnits: []string{}, wantStacks: []string{stackDir}, }, { name: "negation filter - exclude legacy", filterQueries: []string{"!legacy"}, wantUnits: []string{frontendDir, backendDir, dbDir, cacheDir}, wantStacks: []string{stackDir}, }, { name: "negation filter - exclude apps directory", filterQueries: []string{"!./apps/*"}, wantUnits: []string{dbDir, cacheDir}, wantStacks: []string{stackDir}, }, { name: "intersection filter - apps and not legacy", filterQueries: []string{"./apps/* | !legacy"}, wantUnits: []string{frontendDir, backendDir}, wantStacks: []string{}, }, { name: "multiple filters - union semantics", filterQueries: []string{"./apps/frontend", "./libs/db"}, wantUnits: []string{frontendDir, dbDir}, wantStacks: []string{}, }, { name: "braced path filter", filterQueries: []string{"{./apps/*}"}, wantUnits: []string{frontendDir, backendDir, legacyDir}, wantStacks: []string{}, }, { name: "absolute path filter", filterQueries: []string{stackDir}, wantUnits: []string{}, wantStacks: []string{stackDir}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterQueries) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() stacks := configs.Filter(component.StackKind).Paths() assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name) assert.ElementsMatch(t, tt.wantStacks, stacks, "Stacks mismatch for test: %s", tt.name) }) } } // TestDiscovery_FilterEdgeCases tests edge cases in filter handling. func TestDiscovery_FilterEdgeCases(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create a single component for edge case testing unitDir := filepath.Join(tmpDir, "unit #1") err := os.MkdirAll(unitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte(""), 0644) require.NoError(t, err) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() tests := []struct { name string filters []string wantUnits []string wantStacks []string }{ { name: "filter with spaces in path", filters: []string{"{unit #1}"}, wantUnits: []string{unitDir}, wantStacks: []string{}, }, { name: "filter with spaces in name", filters: []string{"unit #1"}, wantUnits: []string{unitDir}, wantStacks: []string{}, }, { name: "non-matching filter", filters: []string{"nonexistent"}, wantUnits: []string{}, wantStacks: []string{}, }, { name: "non-matching path filter", filters: []string{"./nonexistent/*"}, wantUnits: []string{}, wantStacks: []string{}, }, { name: "negation of non-matching filter", filters: []string{"!nonexistent"}, wantUnits: []string{unitDir}, wantStacks: []string{}, }, { name: "empty intersection", filters: []string{"unit #1 | nonexistent"}, wantUnits: []string{}, wantStacks: []string{}, }, { name: "double negation", filters: []string{"!!unit #1"}, wantUnits: []string{unitDir}, wantStacks: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filters) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) configs, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := configs.Filter(component.UnitKind).Paths() stacks := configs.Filter(component.StackKind).Paths() assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name) assert.ElementsMatch(t, tt.wantStacks, stacks, "Stacks mismatch for test: %s", tt.name) }) } } // TestDiscovery_FilterErrorHandling tests error handling for invalid filters. func TestDiscovery_FilterErrorHandling(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(appDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(""), 0644)) tests := []struct { name string filterQueries []string errorExpected bool }{ { name: "invalid filter syntax", filterQueries: []string{"invalid[filter"}, errorExpected: true, }, { name: "empty filter query", filterQueries: []string{""}, errorExpected: true, }, { name: "malformed glob pattern", filterQueries: []string{"./apps/["}, errorExpected: true, }, { name: "invalid attribute key", filterQueries: []string{"invalid=value"}, errorExpected: true, }, { name: "invalid type value", filterQueries: []string{"type=invalid"}, errorExpected: true, }, { name: "invalid external value", filterQueries: []string{"external=maybe"}, errorExpected: true, }, } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Parse filter queries filters, err := filter.ParseFilterQueries(l, tt.filterQueries) // Some errors occur during parsing (like empty filter), others during evaluation if tt.errorExpected && err != nil { // Error occurred during parsing - this is expected for some test cases return } require.NoError(t, err) // Parsing should succeed for evaluation error test cases // Create discovery with filters d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) // Attempt discovery - errors should occur during evaluation _, err = d.Discover(ctx, l, opts) if tt.errorExpected { require.Error(t, err, "Expected error for filter: %v", tt.filterQueries) } else { require.NoError(t, err) } }) } } // TestDiscovery_ExternalAttributeFilter tests external attribute filtering. func TestDiscovery_ExternalAttributeFilter(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create external component outside the working directory to make it truly external internalDir := filepath.Join(tmpDir, "internal") externalDir := filepath.Join(tmpDir, "external") appDir := filepath.Join(internalDir, "app") externalAppDir := filepath.Join(externalDir, "app") dbDir := filepath.Join(internalDir, "db") testDirs := []string{appDir, externalAppDir, dbDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } dependency "external" { config_path = "../../external/app" } `, filepath.Join(dbDir, "terragrunt.hcl"): ``, filepath.Join(externalAppDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: internalDir, RootWorkingDir: internalDir, } ctx := t.Context() t.Run("external=true filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"{./**}... | external=true"}) require.NoError(t, err) d := discovery.NewDiscovery(internalDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{externalAppDir}, units) }) t.Run("external=false filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, []string{"{./**}... | external=false"}) require.NoError(t, err) d := discovery.NewDiscovery(internalDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{appDir, dbDir}, units) }) } // TestDiscovery_DependentDiscovery_Standalone tests standalone dependent discovery (...vpc). // This verifies that ...vpc finds all units that depend on vpc. func TestDiscovery_DependentDiscovery_Standalone(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: app -> db -> vpc // Dependents of vpc: db, app (db depends on vpc, app depends on db which depends on vpc) vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use ...vpc to find all dependents of vpc filters, err := filter.ParseFilterQueries(l, []string{"...vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include vpc (target) and db (direct dependent) and app (transitive dependent) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{vpcDir, dbDir, appDir}, units, "...vpc should find vpc and all its dependents (db, app)") } // TestDiscovery_DependentDiscovery_ExcludeTarget tests dependent discovery with target exclusion (^...vpc). func TestDiscovery_DependentDiscovery_ExcludeTarget(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: app -> vpc vpcDir := filepath.Join(tmpDir, "vpc") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use ...^vpc to find dependents but exclude the target (vpc) filters, err := filter.ParseFilterQueries(l, []string{"...^vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include only app (dependent), not vpc (target is excluded) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{appDir}, units, "...^vpc should find only dependents, not the target") assert.NotContains(t, units, vpcDir, "vpc should be excluded as the target") } // TestDiscovery_DependencyDiscovery_ExcludeTarget tests dependency discovery with target exclusion (^app...). // This is the inverse of TestDiscovery_DependentDiscovery_ExcludeTarget - it tests excluding the target // from the dependency direction rather than the dependent direction. func TestDiscovery_DependencyDiscovery_ExcludeTarget(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: app -> db -> vpc vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use ^app... to find dependencies but exclude the target (app) filters, err := filter.ParseFilterQueries(l, []string{"^app..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include only db and vpc (dependencies), not app (target is excluded) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{dbDir, vpcDir}, units, "^app... should find only dependencies, not the target") assert.NotContains(t, units, appDir, "app should be excluded as the target") } // TestDiscovery_DependentDiscovery_Bidirectional tests bidirectional discovery (...db...). func TestDiscovery_DependentDiscovery_Bidirectional(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: app -> db -> vpc vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use ...db... to find both dependencies and dependents of db filters, err := filter.ParseFilterQueries(l, []string{"...db..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include: app (dependent), db (target), vpc (dependency) units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{appDir, dbDir, vpcDir}, units, "...db... should find dependents, target, and dependencies") } // TestDiscovery_DependentDiscovery_OutsideWorkingDir tests that dependent discovery // finds dependents outside the working directory but within the git root. // This validates the upward filesystem walking feature. func TestDiscovery_DependentDiscovery_OutsideWorkingDir(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize git repository at the root runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create structure: // /repo (git root) // ├── app/ // │ └── vpc/ <- target // │ └── terragrunt.hcl // └── other-app/ // └── consumer/ <- depends on vpc (outside working dir) // └── terragrunt.hcl appDir := filepath.Join(tmpDir, "app") vpcDir := filepath.Join(appDir, "vpc") otherAppDir := filepath.Join(tmpDir, "other-app") consumerDir := filepath.Join(otherAppDir, "consumer") testDirs := []string{vpcDir, consumerDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(vpcDir, "terragrunt.hcl"): ``, filepath.Join(consumerDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../../app/vpc" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() // Set working directory to app/ subdirectory, NOT the git root opts := &options.TerragruntOptions{ WorkingDir: appDir, RootWorkingDir: appDir, } ctx := t.Context() // Use ...vpc to find dependents of vpc // consumer is outside the working directory (app/) but should be found // via upward filesystem walking bounded by git root filters, err := filter.ParseFilterQueries(l, []string{"...vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(appDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: appDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include vpc (target) and consumer (dependent outside working dir) units := components.Filter(component.UnitKind).Paths() assert.Contains(t, units, vpcDir, "vpc should be discovered as the target") assert.Contains(t, units, consumerDir, "consumer should be discovered even though it's outside working dir") assert.ElementsMatch(t, []string{vpcDir, consumerDir}, units, "...vpc should find vpc and consumer (outside working dir)") } // TestDiscovery_DependentDiscovery_OutsideWorkingDir_MultipleLevels tests that dependent discovery // finds dependents at multiple levels outside the working directory. func TestDiscovery_DependentDiscovery_OutsideWorkingDir_MultipleLevels(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize git repository at the root runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create structure: // /repo (git root) // ├── infra/ // │ └── vpc/ <- target // │ └── terragrunt.hcl // ├── services/ // │ └── api/ <- depends on vpc (sibling directory) // │ └── terragrunt.hcl // └── apps/ // └── frontend/ <- depends on api (transitive dependent of vpc) // └── terragrunt.hcl infraDir := filepath.Join(tmpDir, "infra") vpcDir := filepath.Join(infraDir, "vpc") servicesDir := filepath.Join(tmpDir, "services") apiDir := filepath.Join(servicesDir, "api") appsDir := filepath.Join(tmpDir, "apps") frontendDir := filepath.Join(appsDir, "frontend") testDirs := []string{vpcDir, apiDir, frontendDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(vpcDir, "terragrunt.hcl"): ``, filepath.Join(apiDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../../infra/vpc" } `, filepath.Join(frontendDir, "terragrunt.hcl"): ` dependency "api" { config_path = "../../services/api" } `, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() // Set working directory to infra/ subdirectory opts := &options.TerragruntOptions{ WorkingDir: infraDir, RootWorkingDir: infraDir, } ctx := t.Context() // Use ...vpc to find dependents of vpc // api and frontend are both outside the working directory (infra/) filters, err := filter.ParseFilterQueries(l, []string{"...vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(infraDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: infraDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include vpc (target), api (direct dependent), and frontend (transitive dependent) units := components.Filter(component.UnitKind).Paths() assert.Contains(t, units, vpcDir, "vpc should be discovered as the target") assert.Contains(t, units, apiDir, "api should be discovered (direct dependent outside working dir)") assert.Contains(t, units, frontendDir, "frontend should be discovered (transitive dependent outside working dir)") assert.ElementsMatch(t, []string{vpcDir, apiDir, frontendDir}, units) } // TestDiscovery_DependentDiscovery_DirectDependentOnly tests that dependent discovery // finds direct dependents correctly. func TestDiscovery_DependentDiscovery_DirectDependentOnly(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // To speed up this test, make the temporary directory a git repository. runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) // Create dependency graph: api -> db, web -> db // Both api and web directly depend on db dbDir := filepath.Join(tmpDir, "db") apiDir := filepath.Join(tmpDir, "api") webDir := filepath.Join(tmpDir, "web") unrelatedDir := filepath.Join(tmpDir, "unrelated") testDirs := []string{dbDir, apiDir, webDir, unrelatedDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(apiDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(webDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ``, filepath.Join(unrelatedDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use ...db to find all dependents of db filters, err := filter.ParseFilterQueries(l, []string{"...db"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should include db (target), api (dependent), web (dependent) // Should NOT include unrelated units := components.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{dbDir, apiDir, webDir}, units, "...db should find db and all its dependents") assert.NotContains(t, units, unrelatedDir, "unrelated should not be included") } // TestDiscovery_NegatedGraphFilters tests that negated graph expressions correctly // trigger the graph discovery phase and produce the expected results. // // Example with dependency chain: app -> db -> vpc (app depends on db, db depends on vpc) // - `!...db` should exclude db and everything that depends on db (db and app) // - `!db...` should exclude db and everything db depends on (db and vpc) func TestDiscovery_NegatedGraphFilters(t *testing.T) { t.Parallel() tests := []struct { name string filters []string expectedPaths []string excludedPaths []string }{ { name: "negated dependent filter excludes target and dependents", filters: []string{"!...db"}, expectedPaths: []string{"vpc"}, excludedPaths: []string{"db", "app"}, }, { name: "negated dependency filter excludes target and dependencies", filters: []string{"!db..."}, expectedPaths: []string{"app"}, excludedPaths: []string{"db", "vpc"}, }, { name: "positive filter with negated graph filter", filters: []string{"app...", "!vpc"}, expectedPaths: []string{"app", "db"}, excludedPaths: []string{"vpc"}, }, { name: "negated bidirectional filter", filters: []string{"!...db..."}, expectedPaths: []string{}, excludedPaths: []string{"app", "db", "vpc"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() filters, err := filter.ParseFilterQueries(l, tt.filters) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) paths := components.Filter(component.UnitKind).Paths() for _, expected := range tt.expectedPaths { expectedPath := filepath.Join(tmpDir, expected) assert.Contains( t, paths, expectedPath, "expected %s to be included for filters %v", expected, tt.filters, ) } for _, excluded := range tt.excludedPaths { excludedPath := filepath.Join(tmpDir, excluded) assert.NotContains( t, paths, excludedPath, "expected %s to be excluded for filters %v", excluded, tt.filters, ) } }) } } ================================================ FILE: internal/discovery/graph_option.go ================================================ package discovery import "github.com/gruntwork-io/terragrunt/internal/runner/common" type graphTargetOption struct { target string } // WithGraphTarget returns an option that, when applied to the runner stack, // marks a graph target that discovery will use to prune the run to the target // path and its dependents. Apply is a no-op; discovery picks this up via // Discovery.WithOptions by asserting for GraphTarget() on options. func WithGraphTarget(targetDir string) common.Option { return graphTargetOption{target: targetDir} } // Apply is a no-op; discovery consumes the marker via WithOptions. func (o graphTargetOption) Apply(stack common.StackRunner) {} // GraphTarget exposes the requested graph target for discovery to consume. func (o graphTargetOption) GraphTarget() string { return o.target } ================================================ FILE: internal/discovery/graph_target_test.go ================================================ package discovery_test import ( "os" "os/exec" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) // Test that WithGraphTarget retains the target and all dependents. func TestDiscoveryWithGraphTarget_RetainsTargetAndDependents(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize a git repository in the temp directory so dependent discovery bounds traversal to the repo root. cmd := exec.CommandContext(t.Context(), "git", "init") cmd.Dir = tmpDir cmd.Env = os.Environ() require.NoError(t, cmd.Run()) // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(vpcDir, 0o755)) require.NoError(t, os.MkdirAll(dbDir, 0o755)) require.NoError(t, os.MkdirAll(appDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(` dependency "vpc" { config_path = "../vpc" } `), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(` dependency "db" { config_path = "../db" } `), 0o644)) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir depsFilters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithFilters(depsFilters). WithGraphTarget(vpcDir) configs, err := d.Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) paths := configs.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{vpcDir, dbDir, appDir}, paths) } // Test parity: experiment ON via filter queries vs graphTarget marker path func TestDiscoveryGraphTarget_ParityWithFilterQueries(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize a git repository in the temp directory so dependent discovery bounds traversal to the repo root. cmd := exec.CommandContext(t.Context(), "git", "init") cmd.Dir = tmpDir cmd.Env = os.Environ() require.NoError(t, cmd.Run()) // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(vpcDir, 0o755)) require.NoError(t, os.MkdirAll(dbDir, 0o755)) require.NoError(t, os.MkdirAll(appDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(` dependency "vpc" { config_path = "../vpc" } `), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(` dependency "db" { config_path = "../db" } `), 0o644)) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir // Path A: filter queries (experiment ON equivalent) filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{`...{` + vpcDir + `}`}) require.NoError(t, err) depsFilters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) require.NoError(t, err) configsA, err := discovery.NewDiscovery(tmpDir). WithFilters(depsFilters). WithFilters(filters). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) // Path B: graph target marker configsB, err := discovery.NewDiscovery(tmpDir). WithFilters(depsFilters). WithGraphTarget(vpcDir). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) assert.ElementsMatch(t, configsA.Filter(component.UnitKind).Paths(), configsB.Filter(component.UnitKind).Paths()) } // Test that graph target with no dependents returns only the target. func TestDiscoveryWithGraphTarget_NoDependents(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize a git repository cmd := exec.CommandContext(t.Context(), "git", "init") cmd.Dir = tmpDir cmd.Env = os.Environ() require.NoError(t, cmd.Run()) // Create standalone units (no dependencies between them) vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") require.NoError(t, os.MkdirAll(vpcDir, 0o755)) require.NoError(t, os.MkdirAll(dbDir, 0o755)) require.NoError(t, os.MkdirAll(appDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(``), 0o644)) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir d := discovery.NewDiscovery(tmpDir). WithRelationships(). WithGraphTarget(vpcDir) configs, err := d.Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) paths := configs.Filter(component.UnitKind).Paths() // Should only return the target since no one depends on it assert.ElementsMatch(t, []string{vpcDir}, paths) } // Test that WithOptions interface assertion works for GraphTarget. func TestDiscoveryWithOptions_GraphTarget(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize a git repository cmd := exec.CommandContext(t.Context(), "git", "init") cmd.Dir = tmpDir cmd.Env = os.Environ() require.NoError(t, cmd.Run()) // Create dependency chain: vpc -> db vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") require.NoError(t, os.MkdirAll(vpcDir, 0o755)) require.NoError(t, os.MkdirAll(dbDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(` dependency "vpc" { config_path = "../vpc" } `), 0o644)) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir // Create an option that implements GraphTarget() interface graphTargetOpt := &mockGraphTargetOption{target: vpcDir} d := discovery.NewDiscovery(tmpDir). WithRelationships(). WithOptions(graphTargetOpt) configs, err := d.Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) paths := configs.Filter(component.UnitKind).Paths() assert.ElementsMatch(t, []string{vpcDir, dbDir}, paths) } // mockGraphTargetOption implements the GraphTarget() interface for testing. type mockGraphTargetOption struct { target string } func (m *mockGraphTargetOption) GraphTarget() string { return m.target } ================================================ FILE: internal/discovery/helpers.go ================================================ package discovery import ( "os" "path/filepath" "slices" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/zclconf/go-cty/cty" ) const ( // defaultDiscoveryWorkers is the default number of concurrent workers for discovery operations. defaultDiscoveryWorkers = 4 // maxDiscoveryWorkers is the maximum number of workers (2x default to prevent excessive concurrency). maxDiscoveryWorkers = defaultDiscoveryWorkers * 2 // defaultMaxDependencyDepth is the default maximum dependency depth for discovery. defaultMaxDependencyDepth = 1000 // maxCycleRemovalAttempts is the maximum number of cycle removal attempts. maxCycleRemovalAttempts = 100 ) // DefaultConfigFilenames are the default Terragrunt config filenames used in discovery. var DefaultConfigFilenames = []string{config.DefaultTerragruntConfigPath, config.DefaultStackFile} // stringSet is a thread-safe set of strings using map and RWMutex. // This is more performant than sync.Map for string keys with simple bool values. type stringSet struct { m map[string]struct{} mu sync.RWMutex } // newStringSet creates a new stringSet. func newStringSet() *stringSet { return &stringSet{ m: make(map[string]struct{}), } } // LoadOrStore returns true if the key was already present (loaded), // false if the key was newly stored. func (s *stringSet) LoadOrStore(key string) (loaded bool) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.m[key]; ok { return true } s.m[key] = struct{}{} return false } // Load returns whether the key exists in the set. func (s *stringSet) Load(key string) bool { s.mu.RLock() defer s.mu.RUnlock() _, ok := s.m[key] return ok } // isExternal checks if a component path is outside the given working directory. // A path is considered external if it's not within or equal to the working directory. // We conservatively evaluate paths as external if we cannot determine their absolute path. func isExternal(workingDir string, componentPath string) bool { if workingDir == "" { return true } workingDirClean := filepath.Clean(workingDir) componentPathClean := filepath.Clean(componentPath) workingDirResolved, err := filepath.EvalSymlinks(workingDirClean) if err != nil { workingDirResolved = workingDirClean } componentPathResolved, err := filepath.EvalSymlinks(componentPathClean) if err != nil { componentPathResolved = componentPathClean } relPath, err := filepath.Rel(workingDirResolved, componentPathResolved) if err != nil { return true } return relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) } // componentFromDependencyPath returns a component for a dependency path. If the path already // exists in the thread-safe components, it returns that. If the path contains a stack file, // it creates a stack. Otherwise, it creates a unit. func componentFromDependencyPath(path string, components *component.ThreadSafeComponents) component.Component { if existing := components.FindByPath(path); existing != nil { return existing } if _, err := os.Stat(filepath.Join(path, config.DefaultStackFile)); err == nil { return component.NewStack(path) } return component.NewUnit(path) } // createComponentFromPath creates a component from a file path if it matches one of the config filenames. // Returns nil if the file doesn't match any of the provided filenames. func createComponentFromPath( path string, filenames []string, discoveryContext *component.DiscoveryContext, ) component.Component { base := filepath.Base(path) dir := filepath.Dir(path) componentOfBase := func(dir, base string) component.Component { if base == config.DefaultStackFile { return component.NewStack(dir) } return component.NewUnit(dir) } for _, fname := range filenames { if base != fname { continue } c := componentOfBase(dir, base) if unit, ok := c.(*component.Unit); ok { unit.SetConfigFile(base) } if discoveryContext != nil { discoveryCtx := discoveryContext.Copy() discoveryCtx.SuggestOrigin(component.OriginPathDiscovery) c.SetDiscoveryContext(discoveryCtx) } return c } return nil } // validateNoCoexistence checks that no directory has both a unit and a stack config file. // Returns a CoexistenceError if a directory contains both. func validateNoCoexistence(results []DiscoveryResult) error { seen := make(map[string]DiscoveryResult, len(results)) for _, result := range results { path := result.Component.Path() if existing, ok := seen[path]; ok && existing.Component.Kind() != result.Component.Kind() { unitFile, stackFile := existing.Component.ConfigFile(), result.Component.ConfigFile() if result.Component.Kind() == component.UnitKind { unitFile, stackFile = result.Component.ConfigFile(), existing.Component.ConfigFile() } return NewCoexistenceError(path, unitFile, stackFile) } seen[path] = result } return nil } // deduplicateResults removes duplicate components from results by path. func deduplicateResults(results []DiscoveryResult) []DiscoveryResult { seen := make(map[string]struct{}, len(results)) unique := make([]DiscoveryResult, 0, len(results)) for _, result := range results { path := result.Component.Path() if _, exists := seen[path]; !exists { seen[path] = struct{}{} unique = append(unique, result) } } return unique } // resultsToComponents extracts the components from discovery results. func resultsToComponents(results []DiscoveryResult) component.Components { components := make(component.Components, 0, len(results)) for _, result := range results { components = append(components, result.Component) } return components } // sanitizeReadFiles clones, removes empty strings, sorts, and deduplicates the file list. func sanitizeReadFiles(files []string) []string { if len(files) == 0 { return []string{} } files = slices.Clone(files) files = slices.DeleteFunc(files, func(file string) bool { return len(file) == 0 }) slices.Sort(files) return slices.Compact(files) } // extractDependencyPaths extracts all dependency paths from a Terragrunt configuration. func extractDependencyPaths(cfg *config.TerragruntConfig, c component.Component) ([]string, error) { if cfg == nil { return nil, nil } maxDedupLen := len(cfg.TerragruntDependencies) if cfg.Dependencies != nil { maxDedupLen += len(cfg.Dependencies.Paths) } deduped := make(map[string]struct{}, maxDedupLen) errs := make([]error, 0, maxDedupLen) for _, dependency := range cfg.TerragruntDependencies { if dependency.Enabled != nil && !*dependency.Enabled { continue } if dependency.ConfigPath.Type() != cty.String { errs = append(errs, errors.New("dependency config path is not a string")) continue } depPath := dependency.ConfigPath.AsString() if !filepath.IsAbs(depPath) { depPath = filepath.Clean(filepath.Join(c.Path(), depPath)) } depPath = util.ResolvePath(depPath) deduped[depPath] = struct{}{} } if cfg.Dependencies != nil { for _, dependency := range cfg.Dependencies.Paths { if !filepath.IsAbs(dependency) { dependency = filepath.Clean(filepath.Join(c.Path(), dependency)) } dependency = util.ResolvePath(dependency) deduped[dependency] = struct{}{} } } depPaths := make([]string, 0, len(deduped)) for depPath := range deduped { depPaths = append(depPaths, depPath) } if len(errs) > 0 { return depPaths, errors.Join(errs...) } return depPaths, nil } ================================================ FILE: internal/discovery/options.go ================================================ package discovery import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) // WithDiscoveryContext sets the discovery context. func (d *Discovery) WithDiscoveryContext(ctx *component.DiscoveryContext) *Discovery { d.discoveryContext = ctx return d } // WithWorktrees sets the worktrees for Git-based filters. func (d *Discovery) WithWorktrees(w *worktrees.Worktrees) *Discovery { d.worktrees = w return d } // WithConfigFilenames sets the config filenames to discover. func (d *Discovery) WithConfigFilenames(filenames []string) *Discovery { d.configFilenames = filenames return d } // WithParserOptions sets custom HCL parser options. func (d *Discovery) WithParserOptions(opts []hclparse.Option) *Discovery { d.parserOptions = opts return d } // WithFilters sets filter queries for component selection. func (d *Discovery) WithFilters(filters filter.Filters) *Discovery { d.filters = filters // If there are any positive filters, exclude by default if d.filters.HasPositiveFilter() { d.excludeByDefault = true } // Check if filters require parsing if _, ok := d.filters.RequiresParse(); ok { d.requiresParse = true } // Collect Git expressions d.gitExpressions = d.filters.UniqueGitFilters() return d } // WithMaxDependencyDepth sets the maximum dependency depth. func (d *Discovery) WithMaxDependencyDepth(depth int) *Discovery { d.maxDependencyDepth = depth return d } // WithNumWorkers sets the number of concurrent workers. func (d *Discovery) WithNumWorkers(numWorkers int) *Discovery { if numWorkers > 0 && numWorkers <= maxDiscoveryWorkers { d.numWorkers = numWorkers } return d } // WithNoHidden excludes hidden directories from discovery. func (d *Discovery) WithNoHidden() *Discovery { d.noHidden = true return d } // WithRequiresParse enables parsing of Terragrunt configurations. func (d *Discovery) WithRequiresParse() *Discovery { d.requiresParse = true return d } // WithParseExclude enables parsing of exclude configurations. func (d *Discovery) WithParseExclude() *Discovery { d.parseExclude = true d.requiresParse = true return d } // WithParseIncludes enables parsing for include configurations. func (d *Discovery) WithParseIncludes() *Discovery { d.parseIncludes = true d.requiresParse = true return d } // WithReadFiles enables parsing for file reading information. func (d *Discovery) WithReadFiles() *Discovery { d.readFiles = true d.requiresParse = true return d } // WithSuppressParseErrors suppresses errors during parsing. func (d *Discovery) WithSuppressParseErrors() *Discovery { d.suppressParseErrors = true return d } // WithBreakCycles enables breaking cycles in the dependency graph. func (d *Discovery) WithBreakCycles() *Discovery { d.breakCycles = true return d } // WithRelationships enables relationship discovery. func (d *Discovery) WithRelationships() *Discovery { d.discoverRelationships = true return d } // WithGitRoot sets the git root directory for dependent discovery boundary. func (d *Discovery) WithGitRoot(gitRoot string) *Discovery { d.gitRoot = gitRoot return d } // WithGraphTarget sets the graph target so discovery can prune to the target and its dependents. func (d *Discovery) WithGraphTarget(target string) *Discovery { d.graphTarget = target return d } // WithOptions ingests runner options and applies any discovery-relevant settings. // Currently, it extracts HCL parser options provided via common.ParseOptionsProvider // and graph target options, and forwards them to discovery's configuration. func (d *Discovery) WithOptions(opts ...any) *Discovery { var parserOptions []hclparse.Option for _, opt := range opts { if p, ok := opt.(interface{ GetParseOptions() []hclparse.Option }); ok { parserOptions = append(parserOptions, p.GetParseOptions()...) } if g, ok := opt.(interface{ GraphTarget() string }); ok { if target := g.GraphTarget(); target != "" { d = d.WithGraphTarget(target) } } } if len(parserOptions) > 0 { d = d.WithParserOptions(parserOptions) } return d } ================================================ FILE: internal/discovery/phase_filesystem.go ================================================ package discovery import ( "context" "io/fs" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // FilesystemPhase walks directories to discover Terragrunt configurations. type FilesystemPhase struct { // numWorkers is the number of concurrent workers. numWorkers int } // NewFilesystemPhase creates a new FilesystemPhase. func NewFilesystemPhase(numWorkers int) *FilesystemPhase { numWorkers = max(numWorkers, defaultDiscoveryWorkers) return &FilesystemPhase{ numWorkers: numWorkers, } } // Name returns the human-readable name of the phase. func (p *FilesystemPhase) Name() string { return "filesystem" } // Kind returns the PhaseKind identifier. func (p *FilesystemPhase) Kind() PhaseKind { return PhaseFilesystem } // Run executes the filesystem discovery phase. func (p *FilesystemPhase) Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) { results := NewPhaseResults() discovery := input.Discovery if discovery == nil { return nil, NewClassificationError("", "discovery configuration is nil") } discoveryContext := discovery.discoveryContext if discoveryContext == nil || discoveryContext.WorkingDir == "" { return nil, NewClassificationError("", "discovery context or working directory is nil") } filenames := discovery.configFilenames if len(filenames) == 0 { filenames = DefaultConfigFilenames } walkFn := filepath.WalkDir if input.Opts != nil && input.Opts.Experiments.Evaluate(experiment.Symlinks) { walkFn = util.WalkDirWithSymlinks } err := walkFn(discoveryContext.WorkingDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } select { case <-ctx.Done(): return ctx.Err() default: } if d.IsDir() { return p.skipDirIfIgnorable(discovery, d.Name()) } result := p.processFile(input, path, filenames) if result == nil { return nil } switch result.Status { case StatusDiscovered: results.AddDiscovered(*result) case StatusCandidate: results.AddCandidate(*result) case StatusExcluded: // Excluded components are not added } return nil }) return results, err } // skipDirIfIgnorable determines if a directory should be skipped during traversal. func (p *FilesystemPhase) skipDirIfIgnorable(discovery *Discovery, dir string) error { if err := util.SkipDirIfIgnorable(dir); err != nil { return err } if discovery.noHidden { if strings.HasPrefix(dir, ".") && dir != "." && dir != ".." { return filepath.SkipDir } } return nil } // processFile processes a single file to determine if it's a Terragrunt configuration // and classifies it as discovered, candidate, or excluded. func (p *FilesystemPhase) processFile( input *PhaseInput, path string, filenames []string, ) *DiscoveryResult { discovery := input.Discovery c := createComponentFromPath(path, filenames, discovery.discoveryContext) if c == nil { return nil } if input.Classifier != nil { ctx := filter.ClassificationContext{} status, reason, graphIdx := input.Classifier.Classify(c, ctx) return &DiscoveryResult{ Component: c, Status: status, Reason: reason, Phase: PhaseFilesystem, GraphExpressionIndex: graphIdx, } } return &DiscoveryResult{ Component: c, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseFilesystem, } } ================================================ FILE: internal/discovery/phase_graph.go ================================================ package discovery import ( "context" "io/fs" "path/filepath" "slices" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "golang.org/x/sync/errgroup" ) // GraphPhase traverses dependency/dependent relationships based on graph expressions. type GraphPhase struct { // numWorkers is the number of concurrent workers. numWorkers int // maxDepth is the maximum depth for dependency traversal. maxDepth int } // graphTraversalState consolidates shared state used across graph traversal functions. type graphTraversalState struct { opts *options.TerragruntOptions discovery *Discovery threadSafeComponents *component.ThreadSafeComponents seenComponents *stringSet results *PhaseResults } // NewGraphPhase creates a new GraphPhase. func NewGraphPhase(numWorkers, maxDepth int) *GraphPhase { numWorkers = max(numWorkers, defaultDiscoveryWorkers) if maxDepth <= 0 { maxDepth = defaultMaxDependencyDepth } return &GraphPhase{ numWorkers: numWorkers, maxDepth: maxDepth, } } // Name returns the human-readable name of the phase. func (p *GraphPhase) Name() string { return "graph" } // Kind returns the PhaseKind identifier. func (p *GraphPhase) Kind() PhaseKind { return PhaseGraph } // Run executes the graph discovery phase. func (p *GraphPhase) Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) { results := NewPhaseResults() discovery := input.Discovery if discovery == nil { return results, nil } classifier := input.Classifier if classifier == nil || !classifier.HasGraphFilters() { for _, candidate := range input.Candidates { if candidate.Reason != CandidacyReasonGraphTarget { results.AddCandidate(candidate) } } return results, nil } graphExprs := classifier.GraphExpressions() if len(graphExprs) == 0 { return results, nil } candidateComponents := resultsToComponents(input.Candidates) allComponents := make([]component.Component, 0, len(input.Components)+len(candidateComponents)) allComponents = append(allComponents, input.Components...) allComponents = append(allComponents, candidateComponents...) threadSafeComponents := component.NewThreadSafeComponents(allComponents) graphTargetCandidates := make([]DiscoveryResult, 0, len(input.Candidates)) otherCandidates := make([]DiscoveryResult, 0, len(input.Candidates)) for _, candidate := range input.Candidates { switch candidate.Reason { case CandidacyReasonGraphTarget: graphTargetCandidates = append(graphTargetCandidates, candidate) case CandidacyReasonPotentialDependent: // Potential dependents are NOT passed through - they're only used // for building the dependency graph. If they're actual dependents, // they'll be discovered during dependent traversal. case CandidacyReasonNone, CandidacyReasonRequiresParse: otherCandidates = append(otherCandidates, candidate) } } for _, candidate := range otherCandidates { results.AddCandidate(candidate) } seenComponents := newStringSet() state := &graphTraversalState{ opts: input.Opts, discovery: discovery, threadSafeComponents: threadSafeComponents, seenComponents: seenComponents, results: results, } var ( errs []error errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, graphExpr := range graphExprs { matchingCandidates := make([]DiscoveryResult, 0, len(graphTargetCandidates)) for _, candidate := range graphTargetCandidates { if candidate.GraphExpressionIndex == graphExpr.Index { matchingCandidates = append(matchingCandidates, candidate) } } if len(matchingCandidates) == 0 { continue } for _, candidate := range matchingCandidates { g.Go(func() error { err := p.processGraphTarget(ctx, l, state, candidate, graphExpr) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() } return nil }) } } if err := g.Wait(); err != nil { errs = append(errs, err) } if len(errs) > 0 { return results, errors.Join(errs...) } return results, nil } // processGraphTarget processes a single graph expression target. func (p *GraphPhase) processGraphTarget( ctx context.Context, l log.Logger, state *graphTraversalState, candidate DiscoveryResult, graphExpr *GraphExpressionInfo, ) error { c := candidate.Component // Always add the target to discovered, regardless of ExcludeTarget. // The final filter evaluation will handle ExcludeTarget appropriately. // We need the target in the result set for the final evaluation to work // (it uses the target as the starting point for traversing dependents). if loaded := state.seenComponents.LoadOrStore(c.Path()); !loaded { state.results.AddDiscovered(DiscoveryResult{ Component: c, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseGraph, }) } if graphExpr.IncludeDependencies { depth := p.maxDepth if graphExpr.DependencyDepth > 0 { depth = graphExpr.DependencyDepth } err := p.discoverDependencies(ctx, l, state, c, depth) if err != nil { return err } } if graphExpr.IncludeDependents { depth := p.maxDepth if graphExpr.DependentDepth > 0 { depth = graphExpr.DependentDepth } err := p.discoverDependents(ctx, l, state, c, depth) if err != nil { return err } if state.discovery.gitRoot != "" { // Use the discovery's workingDir as the starting point for dependent discovery. // This is important when the target was discovered from a worktree - dependents // exist in the original working directory, not in the worktree. startDir := state.discovery.workingDir l.Debugf( "Starting upstream dependent discovery from %s to gitRoot %s", startDir, state.discovery.gitRoot, ) visitedDirs := newStringSet() err := p.discoverDependentsUpstream(ctx, l, state, c, visitedDirs, startDir, depth) if err != nil { return err } } } return nil } // discoverDependencies recursively discovers dependencies of a component. func (p *GraphPhase) discoverDependencies( ctx context.Context, l log.Logger, state *graphTraversalState, c component.Component, depthRemaining int, ) error { if depthRemaining <= 0 { return nil } if _, ok := c.(*component.Stack); ok { return nil } unit, ok := c.(*component.Unit) if !ok { return nil } cfg := unit.Config() if cfg == nil { err := parseComponent(ctx, l, c, state.opts, state.discovery) if err != nil { return err } cfg = unit.Config() } depPaths, err := extractDependencyPaths(cfg, c) if err != nil { return err } if len(depPaths) == 0 { return nil } var ( errs []error errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, depPath := range depPaths { g.Go(func() error { depComponent, err := p.resolveDependency( c, depPath, state.threadSafeComponents, ) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() return nil } if depComponent == nil { return nil } if loaded := state.seenComponents.LoadOrStore(depComponent.Path()); !loaded { state.results.AddDiscovered(DiscoveryResult{ Component: depComponent, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseGraph, }) err = p.discoverDependencies(ctx, l, state, depComponent, depthRemaining-1) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() } } return nil }) } if err := g.Wait(); err != nil { return err } if len(errs) > 0 { return errors.Join(errs...) } return nil } // discoverDependents discovers dependents of a component by traversing the existing graph. func (p *GraphPhase) discoverDependents( ctx context.Context, l log.Logger, state *graphTraversalState, c component.Component, depthRemaining int, ) error { if depthRemaining <= 0 { return nil } dependents := c.Dependents() if len(dependents) == 0 { return nil } var ( errs []error errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, dependent := range dependents { g.Go(func() error { if loaded := state.seenComponents.LoadOrStore(dependent.Path()); loaded { return nil } state.results.AddDiscovered(DiscoveryResult{ Component: dependent, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseGraph, }) err := p.discoverDependents(ctx, l, state, dependent, depthRemaining-1) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() } return nil }) } if err := g.Wait(); err != nil { return err } if len(errs) > 0 { return errors.Join(errs...) } return nil } // upstreamDiscoveryState holds shared state for processing upstream candidates. // Created once per discoverDependentsUpstream call and reused across candidates. type upstreamDiscoveryState struct { graphTraversalState *graphTraversalState target component.Component checkedForTarget *stringSet errs *[]error errMu *sync.Mutex resolvedTargetPath string targetRelSuffix string resolvedDiscoveryWorkingDir string } // discoverDependentsUpstream discovers dependents by walking up the filesystem // from the target component's directory to gitRoot (or filesystem root if gitRoot is empty). // At each directory level, it walks down to find terragrunt configs and checks if they // depend on the target component. func (p *GraphPhase) discoverDependentsUpstream( ctx context.Context, l log.Logger, state *graphTraversalState, target component.Component, visitedDirs *stringSet, currentDir string, depthRemaining int, ) error { l.Debugf("discoverDependentsUpstream: target=%s currentDir=%s depth=%d", target.Path(), currentDir, depthRemaining) if depthRemaining <= 0 { l.Debugf("discoverDependentsUpstream: depth limit reached") return nil } if currentDir == filepath.Dir(currentDir) { l.Debugf("discoverDependentsUpstream: reached filesystem root") return nil } gitRoot := state.discovery.gitRoot if gitRoot != "" && currentDir != gitRoot && !strings.HasPrefix(currentDir, gitRoot) { l.Debugf("discoverDependentsUpstream: outside git root boundary (currentDir=%s, gitRoot=%s)", currentDir, gitRoot) return nil } resolvedTargetPath := util.ResolvePath(target.Path()) // When the target is from a worktree, we need to compare using relative suffixes // because the absolute paths will differ (worktree vs original directory). // We resolve paths to handle symlinks (e.g., /var -> /private/var on macOS). targetRelSuffix := "" if targetDCtx := target.DiscoveryContext(); targetDCtx != nil && targetDCtx.WorkingDir != "" { resolvedWorkingDir := util.ResolvePath(targetDCtx.WorkingDir) targetRelSuffix = strings.TrimPrefix(resolvedTargetPath, resolvedWorkingDir) } // Resolve discovery.workingDir for consistent path comparison. resolvedDiscoveryWorkingDir := util.ResolvePath(state.discovery.workingDir) var candidates []component.Component walkFn := filepath.WalkDir if state.opts != nil && state.opts.Experiments.Evaluate(experiment.Symlinks) { walkFn = util.WalkDirWithSymlinks } err := walkFn(currentDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } select { case <-ctx.Done(): return ctx.Err() default: } if d.IsDir() { if loaded := visitedDirs.LoadOrStore(path); loaded { return filepath.SkipDir } if err := util.SkipDirIfIgnorable(d.Name()); err != nil { return err } return nil } base := filepath.Base(path) if !slices.Contains(state.discovery.configFilenames, base) { return nil } candidate := createComponentFromPath(path, state.discovery.configFilenames, state.discovery.discoveryContext) if candidate != nil { candidates = append(candidates, candidate) } return nil }) if err != nil { return err } var ( discoveredDependents []component.Component dependentsMu sync.Mutex errs []error errMu sync.Mutex ) upstreamState := &upstreamDiscoveryState{ graphTraversalState: state, target: target, checkedForTarget: newStringSet(), resolvedTargetPath: resolvedTargetPath, targetRelSuffix: targetRelSuffix, resolvedDiscoveryWorkingDir: resolvedDiscoveryWorkingDir, errs: &errs, errMu: &errMu, } g, gCtx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, candidate := range candidates { g.Go(func() error { dependent := p.processUpstreamCandidate(gCtx, l, upstreamState, candidate) if dependent != nil { dependentsMu.Lock() discoveredDependents = append(discoveredDependents, dependent) dependentsMu.Unlock() } return nil }) } if err := g.Wait(); err != nil { return err } for _, dependent := range discoveredDependents { if loaded := state.seenComponents.LoadOrStore(dependent.Path()); loaded { continue } l.Debugf("Found dependent during upstream walk: %s (depends on target), adding to results", dependent.Path()) state.results.AddDiscovered(DiscoveryResult{ Component: dependent, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseGraph, }) l.Debugf("Successfully added %s to results", dependent.Path()) freshVisitedDirs := newStringSet() l.Debugf("Recursively discovering dependents of %s from %s", dependent.Path(), filepath.Dir(dependent.Path())) err := p.discoverDependentsUpstream( ctx, l, state, dependent, freshVisitedDirs, filepath.Dir(dependent.Path()), depthRemaining-1, ) if err != nil { errs = append(errs, err) } } parentDir := filepath.Dir(currentDir) if parentDir != currentDir && depthRemaining > 0 { err := p.discoverDependentsUpstream( ctx, l, state, target, visitedDirs, parentDir, depthRemaining-1, ) if err != nil { errs = append(errs, err) } } if len(errs) > 0 { return errors.Join(errs...) } return nil } // processUpstreamCandidate processes a single candidate to check if it depends on the target. // Returns the canonical component if it depends on the target, nil otherwise. // This function is designed to be called concurrently from multiple goroutines. func (p *GraphPhase) processUpstreamCandidate( ctx context.Context, l log.Logger, state *upstreamDiscoveryState, candidate component.Component, ) component.Component { if loaded := state.checkedForTarget.LoadOrStore(candidate.Path()); loaded { return nil } if state.graphTraversalState.seenComponents.Load(candidate.Path()) { return nil } if _, ok := candidate.(*component.Stack); ok { return nil } if candidate.Path() == state.target.Path() { return nil } unit, ok := candidate.(*component.Unit) if !ok { return nil } cfg := unit.Config() if cfg == nil { err := parseComponent(ctx, l, candidate, state.graphTraversalState.opts, state.graphTraversalState.discovery) if err != nil { if !state.graphTraversalState.discovery.suppressParseErrors { state.errMu.Lock() *state.errs = append(*state.errs, err) state.errMu.Unlock() } return nil } cfg = unit.Config() } deps, err := extractDependencyPaths(cfg, candidate) if err != nil { state.errMu.Lock() *state.errs = append(*state.errs, err) state.errMu.Unlock() return nil } canonicalCandidate, created := state.graphTraversalState.threadSafeComponents.EnsureComponent(candidate) if created { dCtx := state.target.DiscoveryContext() if dCtx != nil { copiedCtx := dCtx.CopyWithNewOrigin(component.OriginGraphDiscovery) // Clear the Ref and related args for graph-discovered components. // They shouldn't inherit the git ref from the target, as this would // cause them to match git filters and become targets themselves. copiedCtx.Ref = "" copiedCtx.Args = slices.DeleteFunc(copiedCtx.Args, func(arg string) bool { return arg == "-destroy" }) canonicalCandidate.SetDiscoveryContext(copiedCtx) } } dependsOnTarget := false for _, dep := range deps { depComponent := componentFromDependencyPath(dep, state.graphTraversalState.threadSafeComponents) depComponent, _ = state.graphTraversalState.threadSafeComponents.EnsureComponent(depComponent) parentCtx := canonicalCandidate.DiscoveryContext() if parentCtx != nil && isExternal(parentCtx.WorkingDir, dep) { if ext, ok := depComponent.(*component.Unit); ok { ext.SetExternal() } } // Compare paths: first try exact match, then try relative suffix match // for worktree scenarios where target is in a different directory. resolvedDep := util.ResolvePath(dep) switch { case resolvedDep == state.resolvedTargetPath: // Direct match - link to the existing depComponent canonicalCandidate.AddDependency(depComponent) dependsOnTarget = true case state.targetRelSuffix != "": // Compare relative suffixes when target is from a worktree. // Use resolved paths to handle symlinks consistently. depRelSuffix := strings.TrimPrefix(resolvedDep, state.resolvedDiscoveryWorkingDir) if depRelSuffix == state.targetRelSuffix { // The dependency path matches the target's relative suffix. // Link to the actual target component instead of the path-based component, // so that the dependent relationship is properly established. canonicalCandidate.AddDependency(state.target) dependsOnTarget = true } else { canonicalCandidate.AddDependency(depComponent) } default: canonicalCandidate.AddDependency(depComponent) } } if dependsOnTarget { return canonicalCandidate } return nil } // resolveDependency resolves a dependency path to a component. func (p *GraphPhase) resolveDependency( parent component.Component, depPath string, threadSafeComponents *component.ThreadSafeComponents, ) (component.Component, error) { parentCtx := parent.DiscoveryContext() if parentCtx == nil { return nil, NewMissingDiscoveryContextError(parent.Path()) } if parentCtx.WorkingDir == "" { return nil, NewMissingWorkingDirectoryError(parent.Path()) } depComponent := componentFromDependencyPath(depPath, threadSafeComponents) addedComponent, created := threadSafeComponents.EnsureComponent(depComponent) if created { copiedCtx := parentCtx.CopyWithNewOrigin(component.OriginGraphDiscovery) // Clear the Ref and related args for graph-discovered dependencies. // They shouldn't inherit the git ref from the parent, as this would // cause them to match git filters and become targets themselves. copiedCtx.Ref = "" copiedCtx.Args = slices.DeleteFunc(copiedCtx.Args, func(arg string) bool { return arg == "-destroy" }) depComponent.SetDiscoveryContext(copiedCtx) } if isExternal(parentCtx.WorkingDir, depPath) { if ext, ok := addedComponent.(*component.Unit); ok { ext.SetExternal() } } parent.AddDependency(addedComponent) return addedComponent, nil } ================================================ FILE: internal/discovery/phase_parse.go ================================================ package discovery import ( "context" "io" "path/filepath" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/hashicorp/hcl/v2" "golang.org/x/sync/errgroup" ) // ParsePhase parses HCL configurations for filter evaluation. type ParsePhase struct { // numWorkers is the number of concurrent workers. numWorkers int } // NewParsePhase creates a new ParsePhase. func NewParsePhase(numWorkers int) *ParsePhase { numWorkers = max(numWorkers, defaultDiscoveryWorkers) return &ParsePhase{ numWorkers: numWorkers, } } // Name returns the human-readable name of the phase. func (p *ParsePhase) Name() string { return "parse" } // Kind returns the PhaseKind identifier. func (p *ParsePhase) Kind() PhaseKind { return PhaseParse } // Run executes the parse phase. func (p *ParsePhase) Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) { results := NewPhaseResults() discovery := input.Discovery componentsToParse := make([]DiscoveryResult, 0, len(input.Candidates)) for _, candidate := range input.Candidates { if candidate.Reason == CandidacyReasonRequiresParse { componentsToParse = append(componentsToParse, candidate) continue } results.AddCandidate(candidate) } // When readFiles, parseExclude, or parseIncludes is enabled, also parse discovered components // to populate the Reading field, Exclude configuration, or ProcessedIncludes even without filters if discovery.readFiles || discovery.parseExclude || discovery.parseIncludes { for _, c := range input.Components { componentsToParse = append(componentsToParse, DiscoveryResult{ Component: c, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseParse, }) } } if len(componentsToParse) == 0 { return results, nil } var ( errs []error errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, candidate := range componentsToParse { g.Go(func() error { result, err := p.parseAndReclassify(ctx, l, input.Opts, discovery, candidate) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() // Return nil to continue processing other components return nil //nolint:nilerr } if result == nil { return nil } switch result.Status { case StatusDiscovered: results.AddDiscovered(*result) case StatusCandidate: results.AddCandidate(*result) case StatusExcluded: // Excluded components are not added } return nil }) } if err := g.Wait(); err != nil { errs = append(errs, err) } if len(errs) > 0 { return results, errors.Join(errs...) } return results, nil } // parseAndReclassify parses a component and reclassifies it based on filter evaluation. func (p *ParsePhase) parseAndReclassify( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, discovery *Discovery, candidate DiscoveryResult, ) (*DiscoveryResult, error) { c := candidate.Component if err := parseComponent(ctx, l, c, opts, discovery); err != nil { if discovery.suppressParseErrors { l.Debugf("Suppressed parse error for %s: %v", c.Path(), err) return &DiscoveryResult{ Component: c, Status: StatusExcluded, Reason: CandidacyReasonNone, Phase: PhaseParse, }, nil } return nil, err } if discovery.classifier != nil { for _, expr := range discovery.classifier.ParseExpressions() { matched, err := filter.Evaluate(l, expr, component.Components{c}) if err != nil { l.Debugf("Error evaluating parse expression for %s: %v", c.Path(), err) continue } if len(matched) > 0 { return &DiscoveryResult{ Component: c, Status: StatusDiscovered, Reason: CandidacyReasonNone, Phase: PhaseParse, }, nil } } classCtx := filter.ClassificationContext{ParseDataAvailable: true} status, reason, graphIdx := discovery.classifier.Classify(c, classCtx) return &DiscoveryResult{ Component: c, Status: status, Reason: reason, Phase: PhaseParse, GraphExpressionIndex: graphIdx, }, nil } return &DiscoveryResult{ Component: c, Status: candidate.Status, Reason: candidate.Reason, Phase: PhaseParse, }, nil } // parseComponent parses a Terragrunt configuration. func parseComponent( ctx context.Context, l log.Logger, c component.Component, opts *options.TerragruntOptions, discovery *Discovery, ) error { parseOpts := opts.Clone() componentPath := c.Path() workingDir := componentPath if util.FileExists(componentPath) && !util.IsDir(componentPath) { workingDir = filepath.Dir(componentPath) } configFilename := config.DefaultTerragruntConfigPath switch c.(type) { case *component.Stack: configFilename = config.DefaultStackFile default: if unit, ok := c.(*component.Unit); ok && unit.ConfigFile() != "" { configFilename = unit.ConfigFile() break } if opts.TerragruntConfigPath != "" && !util.IsDir(opts.TerragruntConfigPath) { configFilename = filepath.Base(opts.TerragruntConfigPath) } } parseOpts.WorkingDir = workingDir parseOpts.Writers.Writer = io.Discard parseOpts.Writers.ErrWriter = io.Discard parseOpts.SkipOutput = true parseOpts.TerragruntConfigPath = filepath.Join(parseOpts.WorkingDir, configFilename) parseOpts.OriginalTerragruntConfigPath = parseOpts.TerragruntConfigPath ctx, parsingCtx := configbridge.NewParsingContext(ctx, l, parseOpts) parsingCtx = parsingCtx.WithDecodeList( config.TerraformSource, config.DependenciesBlock, config.DependencyBlock, config.TerragruntFlags, config.FeatureFlagsBlock, config.ExcludeBlock, config.ErrorsBlock, config.RemoteStateBlock, config.TerragruntVersionConstraints, ).WithSkipOutputsResolution() if len(discovery.parserOptions) > 0 { parsingCtx = parsingCtx.WithParseOption(discovery.parserOptions) } if discovery.suppressParseErrors { parserOpts := parsingCtx.ParserOptions parserOpts = append(parserOpts, hclparse.WithDiagnosticsHandler(func( file *hcl.File, hclDiags hcl.Diagnostics, ) (hcl.Diagnostics, error) { l.Debugf("Suppressed parsing errors %v", hclDiags) return nil, nil })) parsingCtx = parsingCtx.WithParseOption(parserOpts) } cfg, err := config.PartialParseConfigFile(ctx, parsingCtx, l, parseOpts.TerragruntConfigPath, nil) if err != nil { if discovery.suppressParseErrors { var notFoundErr config.TerragruntConfigNotFoundError if errors.As(err, ¬FoundErr) { l.Debugf("Skipping missing config during discovery: %s", parseOpts.TerragruntConfigPath) return nil } } if !discovery.suppressParseErrors || cfg == nil { return err } l.Debugf("Suppressing parse error for %s: %s", parseOpts.TerragruntConfigPath, err) } if unit, ok := c.(*component.Unit); ok { unit.StoreConfig(cfg) } if parsingCtx.FilesRead != nil { readFiles := sanitizeReadFiles(*parsingCtx.FilesRead) c.SetReading(readFiles...) } return nil } ================================================ FILE: internal/discovery/phase_relationship.go ================================================ package discovery import ( "context" "slices" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "golang.org/x/sync/errgroup" ) // RelationshipPhase builds dependency relationships between discovered components. // It discovers dependencies of "orphan" components (those without known dependencies) // to build a complete dependency graph for execution ordering. type RelationshipPhase struct { // numWorkers is the number of concurrent workers. numWorkers int // maxDepth is the maximum depth for relationship discovery. maxDepth int } // relationshipTraversalState consolidates state for relationship discovery. type relationshipTraversalState struct { opts *options.TerragruntOptions discovery *Discovery allComponents *component.Components interTransientComponents *component.ThreadSafeComponents } // NewRelationshipPhase creates a new RelationshipPhase. func NewRelationshipPhase(numWorkers, maxDepth int) *RelationshipPhase { numWorkers = max(numWorkers, defaultDiscoveryWorkers) if maxDepth <= 0 { maxDepth = defaultMaxDependencyDepth } return &RelationshipPhase{ numWorkers: numWorkers, maxDepth: maxDepth, } } // Name returns the human-readable name of the phase. func (p *RelationshipPhase) Name() string { return "relationship" } // Kind returns the PhaseKind identifier. func (p *RelationshipPhase) Kind() PhaseKind { return PhaseRelationship } // Run executes the relationship discovery phase. func (p *RelationshipPhase) Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) { results := NewPhaseResults() err := p.runRelationshipDiscovery(ctx, l, input, results) return results, err } // runRelationshipDiscovery performs the actual relationship discovery. func (p *RelationshipPhase) runRelationshipDiscovery( ctx context.Context, l log.Logger, input *PhaseInput, _ *PhaseResults, ) error { discovery := input.Discovery if discovery == nil || !discovery.discoverRelationships { return nil } interTransientComponents := component.NewThreadSafeComponents(component.Components{}) state := &relationshipTraversalState{ opts: input.Opts, discovery: discovery, allComponents: &input.Components, interTransientComponents: interTransientComponents, } var ( errs = make([]error, 0, len(input.Components)) errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, c := range input.Components { // terminalTracker tracks components that, if encountered, indicate we can stop // traversal, as they are terminal components in the dependency graph. terminalTracker := newTerminalTracker(slices.Collect(func(yield func(component.Component) bool) { for _, rc := range input.Components { if rc != nil && rc != c { if !yield(rc) { return } } } })) g.Go(func() error { err := p.discoverRelationships(ctx, l, state, c, terminalTracker, p.maxDepth) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() } return nil }) } if err := g.Wait(); err != nil { errs = append(errs, err) } if len(errs) > 0 { return errors.Join(errs...) } return nil } // discoverRelationships discovers dependencies for a single component. func (p *RelationshipPhase) discoverRelationships( ctx context.Context, l log.Logger, state *relationshipTraversalState, c component.Component, tracker *terminalTracker, depthRemaining int, ) error { if depthRemaining <= 0 { return nil } if _, ok := c.(*component.Stack); ok { return nil } unit, ok := c.(*component.Unit) if !ok { return nil } cfg := unit.Config() if cfg == nil { err := parseComponent(ctx, l, c, state.opts, state.discovery) if err != nil { return err } cfg = unit.Config() } paths, err := extractDependencyPaths(cfg, c) if err != nil { return err } if len(paths) == 0 { return nil } depsToDiscover := make(component.Components, 0, len(paths)) for _, path := range paths { dep, created := p.dependencyToDiscover(c, path, state.allComponents, state.interTransientComponents, state.discovery) tracker.remove(dep.Path()) if created { depsToDiscover = append(depsToDiscover, dep) } } if len(depsToDiscover) == 0 { return nil } if tracker.isEmpty() { return nil } var ( errs = make([]error, 0, len(depsToDiscover)) errMu sync.Mutex ) g, ctx := errgroup.WithContext(ctx) g.SetLimit(p.numWorkers) for _, dep := range depsToDiscover { g.Go(func() error { err := p.discoverRelationships( ctx, l, state, dep, tracker, depthRemaining-1, ) if err != nil { errMu.Lock() errs = append(errs, err) errMu.Unlock() } return nil }) } if err := g.Wait(); err != nil { return err } if len(errs) > 0 { return errors.Join(errs...) } return nil } // dependencyToDiscover resolves a dependency path and links it to the component. func (p *RelationshipPhase) dependencyToDiscover( c component.Component, path string, allComponents *component.Components, interTransientComponents *component.ThreadSafeComponents, discovery *Discovery, ) (component.Component, bool) { for _, dep := range *allComponents { if dep.Path() == path { if !slices.Contains(c.Dependencies(), dep) { c.AddDependency(dep) } return dep, false } } newUnit := component.NewUnit(path) dep, created := interTransientComponents.EnsureComponent(newUnit) if discovery.discoveryContext != nil { discoveryCtx := discovery.discoveryContext.Copy() discoveryCtx.SuggestOrigin(component.OriginRelationshipDiscovery) dep.SetDiscoveryContext(discoveryCtx) if isExternal(discoveryCtx.WorkingDir, path) { dep.SetExternal() } } c.AddDependency(dep) return dep, created } // terminalTracker provides thread-safe tracking of terminal components. // Components are removed as they're discovered as dependencies. // When empty, relationship discovery can stop early. type terminalTracker struct { components component.Components mu sync.RWMutex } func newTerminalTracker(components component.Components) *terminalTracker { return &terminalTracker{ components: components, } } func (t *terminalTracker) remove(path string) { t.mu.Lock() defer t.mu.Unlock() t.components = slices.DeleteFunc(t.components, func(tc component.Component) bool { return tc != nil && tc.Path() == path }) } func (t *terminalTracker) isEmpty() bool { t.mu.RLock() defer t.mu.RUnlock() return len(t.components) == 0 } ================================================ FILE: internal/discovery/phase_test.go ================================================ package discovery_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestFilesystemPhase_BasicDiscovery tests the filesystem phase directly. func TestFilesystemPhase_BasicDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create test directory structure unit1Dir := filepath.Join(tmpDir, "unit1") unit2Dir := filepath.Join(tmpDir, "unit2") stackDir := filepath.Join(tmpDir, "stack1") testDirs := []string{unit1Dir, unit2Dir, stackDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(unit1Dir, "terragrunt.hcl"): "", filepath.Join(unit2Dir, "terragrunt.hcl"): "", filepath.Join(stackDir, "terragrunt.stack.hcl"): "", } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() // Run filesystem phase via full discovery d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Verify phase discovered all components units := components.Filter(component.UnitKind) stacks := components.Filter(component.StackKind) assert.Len(t, units, 2) assert.Len(t, stacks, 1) } // TestFilesystemPhase_SkipsIgnorableDirs tests that .git, .terraform, .terragrunt-cache are skipped. func TestFilesystemPhase_SkipsIgnorableDirs(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create valid unit unitDir := filepath.Join(tmpDir, "unit") require.NoError(t, os.MkdirAll(unitDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte(""), 0644)) // Create units in ignorable directories (should be skipped) ignorableDirs := []string{".git", ".terraform", ".terragrunt-cache"} for _, dir := range ignorableDirs { ignorableUnit := filepath.Join(tmpDir, dir, "ignored") require.NoError(t, os.MkdirAll(ignorableUnit, 0755)) require.NoError(t, os.WriteFile(filepath.Join(ignorableUnit, "terragrunt.hcl"), []byte(""), 0644)) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Should only find the valid unit, not the ones in ignorable directories assert.Len(t, components, 1) assert.Equal(t, unitDir, components[0].Path()) } // TestFilesystemPhase_WithNoHidden tests hidden directory filtering. func TestFilesystemPhase_WithNoHidden(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create visible unit visibleDir := filepath.Join(tmpDir, "visible") require.NoError(t, os.MkdirAll(visibleDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(visibleDir, "terragrunt.hcl"), []byte(""), 0644)) // Create hidden unit hiddenDir := filepath.Join(tmpDir, ".hidden", "unit") require.NoError(t, os.MkdirAll(hiddenDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(hiddenDir, "terragrunt.hcl"), []byte(""), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, } ctx := t.Context() t.Run("without noHidden", func(t *testing.T) { t.Parallel() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 2, "Should find both visible and hidden") }) t.Run("with noHidden", func(t *testing.T) { t.Parallel() d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithNoHidden() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 1, "Should find only visible") assert.Equal(t, visibleDir, components[0].Path()) }) } // TestParsePhase_ParsesConfigsForParseRequiredFilters tests that parse phase handles parse-required filters. func TestParsePhase_ParsesConfigsForParseRequiredFilters(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create shared file sharedFile := filepath.Join(tmpDir, "shared.hcl") require.NoError(t, os.WriteFile(sharedFile, []byte(` locals { value = "test" } `), 0644)) // Create unit that reads the shared file unitDir := filepath.Join(tmpDir, "unit") require.NoError(t, os.MkdirAll(unitDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte(` locals { shared = read_terragrunt_config("../shared.hcl") } `), 0644)) l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Filter with reading= attribute requires parsing filters, err := filter.ParseFilterQueries(l, []string{"reading=shared.hcl"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters). WithReadFiles() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) assert.Len(t, components, 1) assert.Equal(t, unitDir, components[0].Path()) } // TestGraphPhase_DependencyDiscovery tests the graph phase dependency discovery. func TestGraphPhase_DependencyDiscovery(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use graph filter to trigger graph phase filters, err := filter.ParseFilterQueries(l, []string{"app..."}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Graph phase should discover all dependencies paths := components.Paths() assert.Contains(t, paths, appDir) assert.Contains(t, paths, dbDir) assert.Contains(t, paths, vpcDir) // Verify dependency relationships are built var appComponent component.Component for _, c := range components { if c.Path() == appDir { appComponent = c break } } require.NotNil(t, appComponent) assert.Contains(t, appComponent.Dependencies().Paths(), dbDir) } // TestGraphPhase_DependentDiscoveryRequiresRelationships tests that dependent discovery // requires relationships to be built first. This is a behavioral test documenting the // current implementation's requirements for dependent traversal. func TestGraphPhase_DependentDiscoveryRequiresRelationships(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Using dependent filter (...vpc) without pre-built relationships // Currently, the implementation requires relationships to be built // before dependent traversal can work (unlike dependency traversal which // parses configs on-the-fly) filters, err := filter.ParseFilterQueries(l, []string{"...vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // The vpc component should always be discovered (it's the target) paths := components.Paths() assert.Contains(t, paths, vpcDir, "vpc should always be included as the target") } // TestRelationshipPhase_BuildsRelationships tests the relationship phase. func TestRelationshipPhase_BuildsRelationships(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create components with dependencies appDir := filepath.Join(tmpDir, "app") dbDir := filepath.Join(tmpDir, "db") testDirs := []string{appDir, dbDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Use WithRelationships to enable relationship phase d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithRelationships() components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // Verify relationships are built var appComponent component.Component for _, c := range components { if c.Path() == appDir { appComponent = c break } } require.NotNil(t, appComponent) depPaths := appComponent.Dependencies().Paths() assert.Contains(t, depPaths, dbDir) } // TestCandidacyClassifier_AnalyzesFiltersCorrectly tests the candidacy classifier analysis. func TestCandidacyClassifier_AnalyzesFiltersCorrectly(t *testing.T) { t.Parallel() l := logger.CreateLogger() tests := []struct { name string filterStrings []string expectHasPositive bool expectHasParseRequired bool expectHasGraphFilters bool expectGraphExprCount int }{ { name: "empty filters", filterStrings: []string{}, expectHasPositive: false, }, { name: "simple path filter", filterStrings: []string{"./foo"}, expectHasPositive: true, }, { name: "negated path filter only", filterStrings: []string{"!./foo"}, expectHasPositive: false, }, { name: "path filter with negation", filterStrings: []string{"./foo", "!./bar"}, expectHasPositive: true, }, { name: "reading attribute filter", filterStrings: []string{"reading=config/*"}, expectHasPositive: true, expectHasParseRequired: true, }, { name: "dependency graph filter", filterStrings: []string{"./foo..."}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "dependent graph filter", filterStrings: []string{"..../foo"}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "exclude target graph filter", filterStrings: []string{"^{./foo}..."}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 1, }, { name: "multiple graph filters", filterStrings: []string{"./foo...", "..../bar"}, expectHasPositive: true, expectHasGraphFilters: true, expectGraphExprCount: 2, }, { name: "name attribute filter", filterStrings: []string{"name=my-app"}, expectHasPositive: true, }, { name: "type attribute filter", filterStrings: []string{"type=unit"}, expectHasPositive: true, }, { name: "external attribute filter", filterStrings: []string{"external=true"}, expectHasPositive: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterStrings) require.NoError(t, err) classifier := filter.NewClassifier(filters) assert.Equal(t, tt.expectHasPositive, classifier.HasPositiveFilters(), "HasPositiveFilters mismatch") assert.Equal(t, tt.expectHasParseRequired, classifier.HasParseRequiredFilters(), "HasParseRequiredFilters mismatch") assert.Equal(t, tt.expectHasGraphFilters, classifier.HasGraphFilters(), "HasGraphFilters mismatch") if tt.expectGraphExprCount > 0 { assert.Len(t, classifier.GraphExpressions(), tt.expectGraphExprCount, "GraphExpressions count mismatch") } }) } } // TestCandidacyClassifier_ClassifiesComponentsCorrectly tests component classification. func TestCandidacyClassifier_ClassifiesComponentsCorrectly(t *testing.T) { t.Parallel() l := logger.CreateLogger() tests := []struct { name string componentPath string workingDir string filterStrings []string expectStatus filter.ClassificationStatus expectReason filter.CandidacyReason }{ { name: "no filters - include by default", filterStrings: []string{}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, }, { name: "matching path filter", filterStrings: []string{"./foo"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, }, { name: "non-matching path filter - exclude by default", filterStrings: []string{"./bar"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusExcluded, expectReason: filter.CandidacyReasonNone, }, { name: "negated filter only - exclude component", filterStrings: []string{"!./foo"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusExcluded, expectReason: filter.CandidacyReasonNone, }, { name: "negated filter only - include other", filterStrings: []string{"!./foo"}, componentPath: "/project/bar", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, }, { name: "graph expression target - candidate", filterStrings: []string{"./foo..."}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusCandidate, expectReason: filter.CandidacyReasonGraphTarget, }, { name: "parse required filter - candidate", filterStrings: []string{"reading=config/*"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusCandidate, expectReason: filter.CandidacyReasonRequiresParse, }, { name: "wildcard path filter match", filterStrings: []string{"./apps/*"}, componentPath: "/project/apps/frontend", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, }, { name: "name filter match", filterStrings: []string{"name=foo"}, componentPath: "/project/foo", workingDir: "/project", expectStatus: filter.StatusDiscovered, expectReason: filter.CandidacyReasonNone, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterStrings) require.NoError(t, err) classifier := filter.NewClassifier(filters) // Create a test component c := component.NewUnit(tt.componentPath) c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: tt.workingDir, }) ctx := filter.ClassificationContext{} status, reason, _ := classifier.Classify(c, ctx) assert.Equal(t, tt.expectStatus, status, "status mismatch") assert.Equal(t, tt.expectReason, reason, "reason mismatch") }) } } // TestClassifier_ParseExpressions tests the ParseExpressions method. func TestClassifier_ParseExpressions(t *testing.T) { t.Parallel() l := logger.CreateLogger() filters, err := filter.ParseFilterQueries(l, []string{"reading=config/*", "reading=shared.hcl"}) require.NoError(t, err) classifier := filter.NewClassifier(filters) parseExprs := classifier.ParseExpressions() assert.Len(t, parseExprs, 2, "Should have 2 parse expressions") } // TestClassifier_NegatedExpressions tests the NegatedExpressions method. func TestClassifier_NegatedExpressions(t *testing.T) { t.Parallel() l := logger.CreateLogger() filters, err := filter.ParseFilterQueries(l, []string{"!./foo", "!./bar", "./baz"}) require.NoError(t, err) classifier := filter.NewClassifier(filters) negatedExprs := classifier.NegatedExpressions() assert.Len(t, negatedExprs, 2, "Should have 2 negated expressions") } // TestClassifier_HasDependentFilters tests the HasDependentFilters method. func TestClassifier_HasDependentFilters(t *testing.T) { t.Parallel() l := logger.CreateLogger() tests := []struct { name string filterStrings []string expectResult bool }{ { name: "no graph filters", filterStrings: []string{"./foo"}, expectResult: false, }, { name: "dependency only filter - app...", filterStrings: []string{"app..."}, expectResult: false, }, { name: "dependent only filter - ...vpc", filterStrings: []string{"...vpc"}, expectResult: true, }, { name: "bidirectional filter - ...db...", filterStrings: []string{"...db..."}, expectResult: true, }, { name: "exclude target dependent - ...^vpc", filterStrings: []string{"...^vpc"}, expectResult: true, }, { name: "multiple filters with dependent", filterStrings: []string{"app...", "...vpc"}, expectResult: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(l, tt.filterStrings) require.NoError(t, err) classifier := filter.NewClassifier(filters) assert.Equal(t, tt.expectResult, classifier.HasDependentFilters(), "HasDependentFilters mismatch") }) } } // TestGraphPhase_DependentDiscovery_WithPreBuiltGraph tests that dependent discovery // works correctly when the dependency graph is pre-built. func TestGraphPhase_DependentDiscovery_WithPreBuiltGraph(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") testDirs := []string{vpcDir, dbDir, appDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } l := logger.CreateLogger() opts := &options.TerragruntOptions{ WorkingDir: tmpDir, RootWorkingDir: tmpDir, } ctx := t.Context() // Using dependent filter (...vpc) should now work with pre-built graph filters, err := filter.ParseFilterQueries(l, []string{"...vpc"}) require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}). WithFilters(filters) components, err := d.Discover(ctx, l, opts) require.NoError(t, err) // With pre-built dependency graph, dependent discovery should now find all dependents paths := components.Paths() assert.Contains(t, paths, vpcDir, "vpc should be included as the target") assert.Contains(t, paths, dbDir, "db should be included as direct dependent of vpc") assert.Contains(t, paths, appDir, "app should be included as transitive dependent of vpc") } ================================================ FILE: internal/discovery/phase_worktree.go ================================================ package discovery import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "io/fs" "os" "path/filepath" "runtime" "slices" "sort" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/log" "golang.org/x/sync/errgroup" ) // WorktreePhase discovers components in Git worktrees for Git-based filters. type WorktreePhase struct { // gitExpressions contains Git filter expressions that require worktree discovery. gitExpressions filter.GitExpressions // numWorkers is the number of concurrent workers. numWorkers int } // NewWorktreePhase creates a new WorktreePhase. func NewWorktreePhase(gitExpressions filter.GitExpressions, numWorkers int) *WorktreePhase { if numWorkers <= 0 { numWorkers = runtime.NumCPU() } return &WorktreePhase{ gitExpressions: gitExpressions, numWorkers: numWorkers, } } // Name returns the human-readable name of the phase. func (p *WorktreePhase) Name() string { return "worktree" } // Kind returns the PhaseKind identifier. func (p *WorktreePhase) Kind() PhaseKind { return PhaseWorktree } // NumWorkers returns the number of concurrent workers. func (p *WorktreePhase) NumWorkers() int { return p.numWorkers } // Run executes the worktree discovery phase. func (p *WorktreePhase) Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) { results := NewPhaseResults() discovery := input.Discovery if discovery == nil || discovery.worktrees == nil { l.Debug("No worktrees provided, skipping worktree discovery") return results, nil } w := discovery.worktrees if len(w.WorktreePairs) == 0 { l.Debug("No worktree pairs available, skipping worktree discovery") return results, nil } discoveredComponents := component.NewThreadSafeComponents(component.Components{}) discoveryGroup, discoveryCtx := errgroup.WithContext(ctx) discoveryGroup.SetLimit(p.numWorkers) for _, pair := range w.WorktreePairs { discoveryGroup.Go(func() error { fromFilters, toFilters, err := pair.Expand() if err != nil { return err } fromToG, fromToCtx := errgroup.WithContext(discoveryCtx) if len(fromFilters) > 0 { fromToG.Go(func() error { components, err := p.discoverInWorktree(fromToCtx, l, input, pair.FromWorktree, fromFilters, FromWorktreeKind) if err != nil { return err } for _, c := range components { discoveredComponents.EnsureComponent(c) } return nil }) } if len(toFilters) > 0 { fromToG.Go(func() error { components, err := p.discoverInWorktree(fromToCtx, l, input, pair.ToWorktree, toFilters, ToWorktreeKind) if err != nil { return err } for _, c := range components { discoveredComponents.EnsureComponent(c) } return nil }) } return fromToG.Wait() }) } discoveryGroup.Go(func() error { components, err := p.discoverChangesInWorktreeStacks(discoveryCtx, l, input, w) if err != nil { return err } for _, c := range components { discoveredComponents.EnsureComponent(c) } return nil }) if err := discoveryGroup.Wait(); err != nil { return nil, err } for _, c := range discoveredComponents.ToComponents() { status, reason, graphIdx := StatusDiscovered, CandidacyReasonNone, -1 if input.Classifier != nil { classCtx := filter.ClassificationContext{} status, reason, graphIdx = input.Classifier.Classify(c, classCtx) } result := DiscoveryResult{ Component: c, Status: status, Reason: reason, Phase: PhaseWorktree, GraphExpressionIndex: graphIdx, } switch result.Status { case StatusDiscovered: results.AddDiscovered(result) case StatusCandidate: results.AddCandidate(result) case StatusExcluded: // Excluded components are not added } } return results, nil } // discoverInWorktree discovers components in a single worktree. func (p *WorktreePhase) discoverInWorktree( ctx context.Context, l log.Logger, input *PhaseInput, wt worktrees.Worktree, filters filter.Filters, kind WorktreeKind, ) (component.Components, error) { discovery := input.Discovery discoveryContext := discovery.discoveryContext.Copy() discoveryContext.Ref = wt.Ref discoveryContext.WorkingDir = wt.Path discoveryContext.SuggestOrigin(component.OriginWorktreeDiscovery) if discoveryContext.Args != nil { argsCopy := make([]string, len(discoveryContext.Args)) copy(argsCopy, discoveryContext.Args) discoveryContext.Args = argsCopy } discoveryContext, err := TranslateDiscoveryContextArgsForWorktree(discoveryContext, kind) if err != nil { return nil, err } subDiscovery := NewDiscovery(wt.Path). WithFilters(filters). WithDiscoveryContext(discoveryContext). WithNumWorkers(p.numWorkers) if discovery.suppressParseErrors { subDiscovery = subDiscovery.WithSuppressParseErrors() } if len(discovery.parserOptions) > 0 { subDiscovery = subDiscovery.WithParserOptions(discovery.parserOptions) } components, err := subDiscovery.Discover(ctx, l, input.Opts) if err != nil { return components, err } return components, nil } // discoverChangesInWorktreeStacks discovers changes in worktree stacks. func (p *WorktreePhase) discoverChangesInWorktreeStacks( ctx context.Context, l log.Logger, input *PhaseInput, w *worktrees.Worktrees, ) (component.Components, error) { discoveredComponents := component.NewThreadSafeComponents(component.Components{}) stackDiff := w.Stacks() g, ctx := errgroup.WithContext(ctx) g.SetLimit(max(1, min(runtime.NumCPU(), len(stackDiff.Added)+len(stackDiff.Removed)+len(stackDiff.Changed)*2))) var ( mu sync.Mutex errs = make([]error, 0, len(stackDiff.Changed)) ) for _, changed := range stackDiff.Changed { g.Go(func() error { components, err := p.walkChangedStack(ctx, l, input, changed.FromStack, changed.ToStack) if err != nil { mu.Lock() errs = append(errs, err) mu.Unlock() return err } for _, c := range components { discoveredComponents.EnsureComponent(c) } return nil }) } if err := g.Wait(); err != nil { return nil, err } if len(errs) > 0 { return nil, errors.Join(errs...) } return discoveredComponents.ToComponents(), nil } // walkChangedStack walks a changed stack and discovers components within it. func (p *WorktreePhase) walkChangedStack( ctx context.Context, l log.Logger, input *PhaseInput, fromStack *component.Stack, toStack *component.Stack, ) (component.Components, error) { discovery := input.Discovery fromDiscoveryContext := discovery.discoveryContext.Copy() fromDiscoveryContext.WorkingDir = fromStack.Path() fromDiscoveryContext.Ref = fromStack.DiscoveryContext().Ref fromDiscoveryContext, err := TranslateDiscoveryContextArgsForWorktree(fromDiscoveryContext, FromWorktreeKind) if err != nil { return nil, err } toDiscoveryContext := discovery.discoveryContext.Copy() toDiscoveryContext.WorkingDir = toStack.Path() toDiscoveryContext.Ref = toStack.DiscoveryContext().Ref toDiscoveryContext, err = TranslateDiscoveryContextArgsForWorktree(toDiscoveryContext, ToWorktreeKind) if err != nil { return nil, err } var fromComponents, toComponents component.Components discoveryGroup, discoveryCtx := errgroup.WithContext(ctx) discoveryGroup.SetLimit(min(runtime.NumCPU(), 2)) //nolint:mnd var ( mu sync.Mutex errs = make([]error, 0, 2) //nolint:mnd ) discoveryGroup.Go(func() error { fromDiscovery := NewDiscovery(fromStack.Path()). WithDiscoveryContext(fromDiscoveryContext). WithFilters(filter.Filters{}). WithNumWorkers(p.numWorkers) var fromDiscoveryErr error fromComponents, fromDiscoveryErr = fromDiscovery.Discover(discoveryCtx, l, input.Opts) if fromDiscoveryErr != nil { mu.Lock() errs = append(errs, fromDiscoveryErr) mu.Unlock() return nil } for _, c := range fromComponents { dc := c.DiscoveryContext().CopyWithNewOrigin(component.OriginWorktreeDiscovery) dc.WorkingDir = fromStack.DiscoveryContext().WorkingDir c.SetDiscoveryContext(dc) } return nil }) discoveryGroup.Go(func() error { toDiscovery := NewDiscovery(toStack.Path()). WithDiscoveryContext(toDiscoveryContext). WithFilters(filter.Filters{}). WithNumWorkers(p.numWorkers) var toDiscoveryErr error toComponents, toDiscoveryErr = toDiscovery.Discover(discoveryCtx, l, input.Opts) if toDiscoveryErr != nil { mu.Lock() errs = append(errs, toDiscoveryErr) mu.Unlock() return nil } for _, c := range toComponents { dc := c.DiscoveryContext().CopyWithNewOrigin(component.OriginWorktreeDiscovery) dc.WorkingDir = toStack.DiscoveryContext().WorkingDir c.SetDiscoveryContext(dc) } return nil }) if err = discoveryGroup.Wait(); err != nil { return nil, err } if len(errs) > 0 { return nil, errors.Join(errs...) } componentPairs, err := MatchComponentPairs(fromComponents, toComponents) if err != nil { return nil, err } finalComponents := make(component.Components, 0, max(len(fromComponents), len(toComponents))) for _, fromComponent := range fromComponents { if !slices.ContainsFunc(componentPairs, func(cp ComponentPair) bool { return cp.FromComponent == fromComponent }) { finalComponents = append(finalComponents, fromComponent) } } for _, toComponent := range toComponents { if !slices.ContainsFunc(componentPairs, func(cp ComponentPair) bool { return cp.ToComponent == toComponent }) { finalComponents = append(finalComponents, toComponent) } } for _, pair := range componentPairs { var fromSHA, toSHA string shaGroup, _ := errgroup.WithContext(ctx) shaGroup.SetLimit(min(runtime.NumCPU(), 2)) //nolint:mnd shaGroup.Go(func() error { var localErr error fromSHA, localErr = GenerateDirSHA256(pair.FromComponent.Path()) return localErr }) shaGroup.Go(func() error { var localErr error toSHA, localErr = GenerateDirSHA256(pair.ToComponent.Path()) return localErr }) if err := shaGroup.Wait(); err != nil { return nil, err } if fromSHA != toSHA { dc := pair.ToComponent.DiscoveryContext().CopyWithNewOrigin(component.OriginWorktreeDiscovery) pair.ToComponent.SetDiscoveryContext(dc) finalComponents = append(finalComponents, pair.ToComponent) } } return finalComponents, nil } // ComponentPair represents a pair of matched components from different worktrees. type ComponentPair struct { FromComponent component.Component ToComponent component.Component } // MatchComponentPairs matches components between from and to stacks by their relative paths. func MatchComponentPairs( fromComponents component.Components, toComponents component.Components, ) ([]ComponentPair, error) { componentPairs := make([]ComponentPair, 0, max(len(fromComponents), len(toComponents))) for _, fromComponent := range fromComponents { if fromComponent.DiscoveryContext() == nil { return nil, NewMissingDiscoveryContextError(fromComponent.Path()) } fromComponentSuffix := strings.TrimPrefix( fromComponent.Path(), fromComponent.DiscoveryContext().WorkingDir, ) for _, toComponent := range toComponents { if toComponent.DiscoveryContext() == nil { return nil, NewMissingDiscoveryContextError(toComponent.Path()) } toComponentSuffix := strings.TrimPrefix( toComponent.Path(), toComponent.DiscoveryContext().WorkingDir, ) if filepath.Clean(fromComponentSuffix) == filepath.Clean(toComponentSuffix) { componentPairs = append(componentPairs, ComponentPair{ FromComponent: fromComponent, ToComponent: toComponent, }) } } } return componentPairs, nil } // WorktreeKind represents the type of worktree (from or to). type WorktreeKind int const ( // FromWorktreeKind represents a "from" worktree (the older reference). FromWorktreeKind WorktreeKind = iota // ToWorktreeKind represents a "to" worktree (the newer reference). ToWorktreeKind ) // TranslateDiscoveryContextArgsForWorktree translates discovery context arguments for a worktree. func TranslateDiscoveryContextArgsForWorktree( discoveryContext *component.DiscoveryContext, wKind WorktreeKind, ) (*component.DiscoveryContext, error) { switch wKind { case FromWorktreeKind: switch { case (discoveryContext.Cmd == "plan" || discoveryContext.Cmd == "apply") && !slices.Contains(discoveryContext.Args, "-destroy"): discoveryContext.Args = append(discoveryContext.Args, "-destroy") case discoveryContext.Cmd == "" && len(discoveryContext.Args) == 0: // Discovery commands like find or list - no args needed default: return discoveryContext, NewGitFilterCommandError(discoveryContext.Cmd, discoveryContext.Args) } return discoveryContext, nil case ToWorktreeKind: switch { case (discoveryContext.Cmd == "plan" || discoveryContext.Cmd == "apply") && !slices.Contains(discoveryContext.Args, "-destroy"): // No -destroy flag needed for to worktrees case discoveryContext.Cmd == "" && len(discoveryContext.Args) == 0: // Discovery commands like find or list - no args needed default: return discoveryContext, NewGitFilterCommandError(discoveryContext.Cmd, discoveryContext.Args) } return discoveryContext, nil default: return discoveryContext, NewGitFilterCommandError(discoveryContext.Cmd, discoveryContext.Args) } } // GenerateDirSHA256 calculates a single SHA256 checksum for all files in a directory. func GenerateDirSHA256(rootDir string) (string, error) { var filePaths []string err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Ignore .terragrunt-stack-manifest as it contains absolute paths if filepath.Base(path) == ".terragrunt-stack-manifest" { return nil } filePaths = append(filePaths, path) return nil }) if err != nil { return "", fmt.Errorf("error walking directory: %w", err) } sort.Strings(filePaths) hash := sha256.New() for _, path := range filePaths { relPath, err := filepath.Rel(rootDir, path) if err != nil { return "", fmt.Errorf("could not compute relative path for %s: %w", path, err) } normalizedPath := filepath.ToSlash(relPath) // These writes are guaranteed to succeed. They just return errors because of the // Writer interface, but we don't care about those errors. _, _ = hash.Write([]byte(normalizedPath)) _, _ = hash.Write([]byte{0}) f, err := os.Open(path) if err != nil { return "", fmt.Errorf("could not open file %s: %w", path, err) } _, err = io.Copy(hash, f) closeErr := f.Close() if err != nil { return "", fmt.Errorf("could not copy file %s to hash: %w", path, err) } if closeErr != nil { return "", fmt.Errorf("could not close file %s: %w", path, closeErr) } } return hex.EncodeToString(hash.Sum(nil)), nil } ================================================ FILE: internal/discovery/phase_worktree_integration_test.go ================================================ package discovery_test import ( "context" "os" "path/filepath" "testing" "time" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/internal/stacks/generate" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestWorktreePhase_Integration_UnitLifecycle tests the full worktree discovery flow // for created, modified, removed, and untouched units. func TestWorktreePhase_Integration_UnitLifecycle(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create initial units createUnit(t, tmpDir, "unit-to-be-modified", `# Unit to be modified`) createUnit(t, tmpDir, "unit-to-be-removed", `# Unit to be removed`) createUnit(t, tmpDir, "unit-to-be-untouched", `# Unit to be untouched`) commitChanges(t, runner, "Initial commit") // Modify the unit err := os.WriteFile(filepath.Join(tmpDir, "unit-to-be-modified", "terragrunt.hcl"), []byte(`# Unit modified`), 0644) require.NoError(t, err) // Remove the unit err = os.RemoveAll(filepath.Join(tmpDir, "unit-to-be-removed")) require.NoError(t, err) // Add a new unit createUnit(t, tmpDir, "unit-to-be-created", `# Unit created`) // Do nothing to the untouched unit commitChanges(t, runner, "Create, modify, and remove units") // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, w := runWorktreeDiscovery(t, tmpDir, gitExpressions, "", nil) // Verify worktrees were created assert.NotEmpty(t, w.WorktreePairs, "Worktrees should be created") assert.Contains(t, w.WorktreePairs, "[HEAD~1...HEAD]", "Worktree should exist") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] fromWorktree := worktreePair.FromWorktree.Path toWorktree := worktreePair.ToWorktree.Path // Verify units were discovered units := components.Filter(component.UnitKind) unitPaths := units.Paths() expectedUnitToBeCreated := filepath.Join(toWorktree, "unit-to-be-created") expectedUnitToBeModified := filepath.Join(toWorktree, "unit-to-be-modified") expectedUnitToBeRemoved := filepath.Join(fromWorktree, "unit-to-be-removed") expectedUnitToBeUntouched := filepath.Join(toWorktree, "unit-to-be-untouched") assert.Contains(t, unitPaths, expectedUnitToBeCreated, "Unit should be discovered as it was created") assert.DirExists(t, expectedUnitToBeCreated) assert.Contains(t, unitPaths, expectedUnitToBeModified, "Unit should be discovered as it was modified") assert.DirExists(t, expectedUnitToBeModified) assert.Contains(t, unitPaths, expectedUnitToBeRemoved, "Unit should be discovered as it was removed") assert.DirExists(t, expectedUnitToBeRemoved) assert.NotContains(t, unitPaths, expectedUnitToBeUntouched, "Unit should not be discovered as it was untouched") assert.DirExists(t, expectedUnitToBeUntouched) } // TestWorktreePhase_Integration_CommandArgs tests command argument handling for worktrees. func TestWorktreePhase_Integration_CommandArgs(t *testing.T) { t.Parallel() gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} tests := []struct { name string cmd string expectedErrorMsg string description string args []string expectError bool }{ { name: "plan_command_removed_unit_has_destroy_flag", cmd: "plan", args: []string{}, expectError: false, description: "Plan command should add '-destroy' flag for removed units", }, { name: "apply_command_removed_unit_has_destroy_flag", cmd: "apply", args: []string{}, expectError: false, description: "Apply command should add '-destroy' flag for removed units", }, { name: "plan_command_with_destroy_throws_error", cmd: "plan", args: []string{"-destroy"}, expectError: true, description: "Plan command with '-destroy' already present should error", }, { name: "empty_command_allowed", cmd: "", args: []string{}, expectError: false, description: "Empty command should be allowed for discovery commands", }, { name: "unsupported_command_returns_error", cmd: "destroy", args: []string{}, expectError: true, expectedErrorMsg: "Git-based filtering is not supported with the command 'destroy'", description: "Unsupported command should return error", }, { name: "plan_with_other_args_allowed", cmd: "plan", args: []string{"-out", "plan.out"}, expectError: false, description: "Plan command with other args should be allowed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Each subtest creates its own git repository tmpDir, runner := setupGitRepo(t) // Create initial units createUnit(t, tmpDir, "unit-to-be-modified", `# Unit to be modified`) createUnit(t, tmpDir, "unit-to-be-removed", `# Unit to be removed`) commitChanges(t, runner, "Initial commit") // Modify the unit err := os.WriteFile(filepath.Join(tmpDir, "unit-to-be-modified", "terragrunt.hcl"), []byte(`# Modified`), 0644) require.NoError(t, err) // Remove the unit err = os.RemoveAll(filepath.Join(tmpDir, "unit-to-be-removed")) require.NoError(t, err) // Add a new unit createUnit(t, tmpDir, "unit-to-be-created", `# Created`) commitChanges(t, runner, "Update units") // Set up discovery l := logger.CreateLogger() w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: tt.cmd, Args: tt.args, } discovery := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w) filters := filter.Filters{} for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } discovery = discovery.WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) if tt.expectError { require.Error(t, err, "Expected error for: %s", tt.description) if tt.expectedErrorMsg != "" { assert.Contains(t, err.Error(), tt.expectedErrorMsg) } return } require.NoError(t, err, "Should not error for: %s", tt.description) // Verify worktrees were created assert.NotEmpty(t, w.WorktreePairs, "Worktrees should be created") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] fromWorktree := worktreePair.FromWorktree.Path toWorktree := worktreePair.ToWorktree.Path // Verify units were discovered units := components.Filter(component.UnitKind) expectedUnitToBeCreated := filepath.Join(toWorktree, "unit-to-be-created") expectedUnitToBeModified := filepath.Join(toWorktree, "unit-to-be-modified") expectedUnitToBeRemoved := filepath.Join(fromWorktree, "unit-to-be-removed") // Verify discovery context args for each unit for _, unit := range units { ctx := unit.DiscoveryContext() require.NotNil(t, ctx, "Component should have discovery context") unitPath := unit.Path() // Check removed unit (discovered in "from" worktree) if unitPath == expectedUnitToBeRemoved { if tt.cmd == "plan" || tt.cmd == "apply" { assert.Contains(t, ctx.Args, "-destroy", "Removed unit should have '-destroy' flag for %s command", tt.cmd) } } // Check added unit (discovered in "to" worktree) if unitPath == expectedUnitToBeCreated { if tt.cmd == "plan" || tt.cmd == "apply" { assert.NotContains(t, ctx.Args, "-destroy", "Added unit should NOT have '-destroy' flag for %s command", tt.cmd) } } // Check modified unit (discovered in "to" worktree) if unitPath == expectedUnitToBeModified { if tt.cmd == "plan" || tt.cmd == "apply" { assert.NotContains(t, ctx.Args, "-destroy", "Modified unit should NOT have '-destroy' flag for %s command", tt.cmd) } } } }) } } // TestWorktreePhase_Integration_EmptyFilters tests that discovery produces no results // when git diff contains no terragrunt files. func TestWorktreePhase_Integration_EmptyFilters(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create initial empty commit err := runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) // Create a second commit with only non-terragrunt files readmePath := filepath.Join(tmpDir, "README.md") err = os.WriteFile(readmePath, []byte("# Test"), 0644) require.NoError(t, err) commitChanges(t, runner, "Update README") // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, _ := runWorktreeDiscovery(t, tmpDir, gitExpressions, "", nil) // Verify that no components were discovered assert.Empty(t, components, "No components should be discovered when filters are empty") } // TestWorktreePhase_Integration_EmptyDiffs tests that discovery produces no results // when there are no changes between commits. func TestWorktreePhase_Integration_EmptyDiffs(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create initial empty commit err := runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) // Create a second empty commit err = runner.GoCommit("Empty commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, _ := runWorktreeDiscovery(t, tmpDir, gitExpressions, "", nil) // Verify that no components were discovered assert.Empty(t, components, "No components should be discovered when there are no diffs") } // TestWorktreePhase_Integration_Stacks tests stack discovery with generated units. func TestWorktreePhase_Integration_Stacks(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a catalog of units legacyUnitDir := filepath.Join(tmpDir, "catalog", "units", "legacy") err := os.MkdirAll(legacyUnitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "terragrunt.hcl"), []byte(`# Legacy unit`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "main.tf"), []byte(`# Intentionally empty`), 0644) require.NoError(t, err) modernUnitDir := filepath.Join(tmpDir, "catalog", "units", "modern") err = os.MkdirAll(modernUnitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(modernUnitDir, "terragrunt.hcl"), []byte(`# Modern unit`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(modernUnitDir, "main.tf"), []byte(`# Intentionally empty`), 0644) require.NoError(t, err) commitChanges(t, runner, "Create catalog units") // Create stacks stackFileContents := `unit "unit_to_be_modified" { source = "${get_repo_root()}/catalog/units/legacy" path = "unit_to_be_modified" } unit "unit_to_be_removed" { source = "${get_repo_root()}/catalog/units/legacy" path = "unit_to_be_removed" } unit "unit_to_be_untouched" { source = "${get_repo_root()}/catalog/units/legacy" path = "unit_to_be_untouched" } ` stackToBeModifiedDir := filepath.Join(tmpDir, "live", "stack-to-be-modified") err = os.MkdirAll(stackToBeModifiedDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackToBeModifiedDir, "terragrunt.stack.hcl"), []byte(stackFileContents), 0644) require.NoError(t, err) stackToBeRemovedDir := filepath.Join(tmpDir, "live", "stack-to-be-removed") err = os.MkdirAll(stackToBeRemovedDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackToBeRemovedDir, "terragrunt.stack.hcl"), []byte(stackFileContents), 0644) require.NoError(t, err) stackToBeUntouchedDir := filepath.Join(tmpDir, "live", "stack-to-be-untouched") err = os.MkdirAll(stackToBeUntouchedDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackToBeUntouchedDir, "terragrunt.stack.hcl"), []byte(stackFileContents), 0644) require.NoError(t, err) commitChanges(t, runner, "Create stacks") // Add a new stack stackToBeAddedDir := filepath.Join(tmpDir, "live", "stack-to-be-added") err = os.MkdirAll(stackToBeAddedDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackToBeAddedDir, "terragrunt.stack.hcl"), []byte(stackFileContents), 0644) require.NoError(t, err) // Modify the first stack modifiedStackContents := `unit "unit_to_be_added" { source = "${get_repo_root()}/catalog/units/modern" path = "unit_to_be_added" } unit "unit_to_be_modified" { source = "${get_repo_root()}/catalog/units/modern" path = "unit_to_be_modified" } unit "unit_to_be_untouched" { source = "${get_repo_root()}/catalog/units/legacy" path = "unit_to_be_untouched" } ` err = os.WriteFile(filepath.Join(stackToBeModifiedDir, "terragrunt.stack.hcl"), []byte(modifiedStackContents), 0644) require.NoError(t, err) // Remove the second stack err = os.RemoveAll(stackToBeRemovedDir) require.NoError(t, err) commitChanges(t, runner, "Modify and remove stacks") // Set up discovery with worktrees l := logger.CreateLogger() gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) // Generate stacks in worktrees opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]"}) require.NoError(t, parseErr) opts.Filters = parsedFilters opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.FilterFlag) require.NoError(t, err) err = generate.GenerateStacks(t.Context(), l, opts, w) require.NoError(t, err) // Run discovery discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: "plan", } discovery := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w) filters := filter.Filters{} for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } discovery = discovery.WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) // Verify that components were discovered assert.NotEmpty(t, components) // Get worktree paths worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] require.NotEmpty(t, worktreePair) fromWorktree := worktreePair.FromWorktree.Path toWorktree := worktreePair.ToWorktree.Path // Get relative paths stackToBeAddedRel, err := filepath.Rel(tmpDir, stackToBeAddedDir) require.NoError(t, err) stackToBeRemovedRel, err := filepath.Rel(tmpDir, stackToBeRemovedDir) require.NoError(t, err) // Verify added stack and its units are in toWorktree addedStackPath := filepath.Join(toWorktree, stackToBeAddedRel) foundAddedStack := false for _, c := range components { if c.Path() == addedStackPath { foundAddedStack = true dc := c.DiscoveryContext() assert.NotNil(t, dc) assert.Equal(t, "HEAD", dc.Ref) break } } assert.True(t, foundAddedStack, "Added stack should be discovered") // Verify removed stack is in fromWorktree removedStackPath := filepath.Join(fromWorktree, stackToBeRemovedRel) foundRemovedStack := false for _, c := range components { if c.Path() == removedStackPath { foundRemovedStack = true dc := c.DiscoveryContext() assert.NotNil(t, dc) assert.Equal(t, "HEAD~1", dc.Ref) assert.Contains(t, dc.Args, "-destroy", "Removed stack should have -destroy flag") break } } assert.True(t, foundRemovedStack, "Removed stack should be discovered") } // TestWorktreePhase_Integration_FileRename tests that file renames are detected. func TestWorktreePhase_Integration_FileRename(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a unit with a file unitDir := createUnit(t, tmpDir, "unit", `# Unit config`) err := os.WriteFile(filepath.Join(unitDir, "original.tf"), []byte(`# Same content before and after rename`), 0644) require.NoError(t, err) commitChanges(t, runner, "Initial commit with original.tf") // Rename the file (same content, different name) err = os.Rename( filepath.Join(unitDir, "original.tf"), filepath.Join(unitDir, "renamed.tf"), ) require.NoError(t, err) commitChanges(t, runner, "Rename original.tf to renamed.tf") // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, w := runWorktreeDiscovery(t, tmpDir, gitExpressions, "plan", nil) // The unit should be detected as changed because the file was renamed assert.NotEmpty(t, components, "Unit with renamed file should be detected as changed") // Verify we have the unit toWorktree := w.WorktreePairs["[HEAD~1...HEAD]"].ToWorktree.Path expectedUnitPath := filepath.Join(toWorktree, "unit") unitPaths := components.Paths() assert.Contains(t, unitPaths, expectedUnitPath, "Should discover the unit with renamed file") } // TestWorktreePhase_Integration_FileMove tests that file moves are detected. func TestWorktreePhase_Integration_FileMove(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a unit with a file in root unitDir := createUnit(t, tmpDir, "unit", `# Unit config`) err := os.WriteFile(filepath.Join(unitDir, "module.tf"), []byte(`# Module content`), 0644) require.NoError(t, err) commitChanges(t, runner, "Initial commit with module.tf in root") // Move file to subdirectory (same content, different path) subDir := filepath.Join(unitDir, "modules") err = os.MkdirAll(subDir, 0755) require.NoError(t, err) err = os.Rename( filepath.Join(unitDir, "module.tf"), filepath.Join(subDir, "module.tf"), ) require.NoError(t, err) commitChanges(t, runner, "Move module.tf to modules/ subdirectory") // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, _ := runWorktreeDiscovery(t, tmpDir, gitExpressions, "plan", nil) // The unit should be detected as changed because the file was moved assert.NotEmpty(t, components, "Unit with moved file should be detected as changed") // Verify we have the unit foundUnit := false for _, c := range components { if _, ok := c.(*component.Unit); ok { foundUnit = true break } } assert.True(t, foundUnit, "Should discover the unit with moved file") } // TestWorktreePhase_Integration_NestedUnits tests discovery of nested units. func TestWorktreePhase_Integration_NestedUnits(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create nested unit structure createUnit(t, tmpDir, "apps/frontend", `# Frontend unit`) createUnit(t, tmpDir, "apps/backend", `# Backend unit`) createUnit(t, tmpDir, "apps/backend/db", `# Database unit`) commitChanges(t, runner, "Initial commit") // Modify the nested unit err := os.WriteFile( filepath.Join(tmpDir, "apps/backend/db", "terragrunt.hcl"), []byte(`# Modified database unit`), 0644, ) require.NoError(t, err) commitChanges(t, runner, "Modify nested unit") // Run worktree discovery gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, w := runWorktreeDiscovery(t, tmpDir, gitExpressions, "", nil) // Verify the nested unit was discovered units := components.Filter(component.UnitKind) assert.Len(t, units, 1, "Only the modified nested unit should be discovered") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] toWorktree := worktreePair.ToWorktree.Path expectedPath := filepath.Join(toWorktree, "apps/backend/db") unitPaths := units.Paths() assert.Contains(t, unitPaths, expectedPath, "Nested unit should be discovered") } // TestWorktreePhase_Integration_MultipleGitExpressions tests discovery with multiple git expressions. func TestWorktreePhase_Integration_MultipleGitExpressions(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create initial unit createUnit(t, tmpDir, "unit-a", `# Unit A`) commitChanges(t, runner, "Initial commit") // Create second unit createUnit(t, tmpDir, "unit-b", `# Unit B`) commitChanges(t, runner, "Add unit B") // Create third unit createUnit(t, tmpDir, "unit-c", `# Unit C`) commitChanges(t, runner, "Add unit C") // Run worktree discovery with expression covering last commit gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} components, w := runWorktreeDiscovery(t, tmpDir, gitExpressions, "", nil) // Should only discover unit-c (added in last commit) units := components.Filter(component.UnitKind) assert.Len(t, units, 1, "Only unit-c should be discovered") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] toWorktree := worktreePair.ToWorktree.Path expectedPath := filepath.Join(toWorktree, "unit-c") unitPaths := units.Paths() assert.Contains(t, unitPaths, expectedPath, "Unit C should be discovered") } // TestWorktreePhase_Integration_GitFilterCombinedWithOtherFilters tests git filters combined // with other filter types (path, name, type, negation). func TestWorktreePhase_Integration_GitFilterCombinedWithOtherFilters(t *testing.T) { t.Parallel() tests := []struct { name string filterQueries func(fromRef, toRef string) []string wantUnits func(fromDir, toDir string) []string wantStacks []string expectedChanged []string // which units are expected to be changed in the test setup }{ { name: "Git filter combined with path filter", filterQueries: func(fromRef, toRef string) []string { return []string{"[" + fromRef + "..." + toRef + "] | ./app"} }, wantUnits: func(_, toDir string) []string { return []string{filepath.Join(toDir, "app")} }, wantStacks: []string{}, expectedChanged: []string{"app", "new"}, }, { name: "Git filter combined with name filter", filterQueries: func(fromRef, toRef string) []string { return []string{"[" + fromRef + "..." + toRef + "] | name=new"} }, wantUnits: func(_, toDir string) []string { return []string{filepath.Join(toDir, "new")} }, wantStacks: []string{}, expectedChanged: []string{"app", "new"}, }, { name: "Git filter with negation", filterQueries: func(fromRef, toRef string) []string { return []string{"[" + fromRef + "..." + toRef + "] | !name=new"} }, wantUnits: func(fromDir, toDir string) []string { return []string{ filepath.Join(fromDir, "cache"), filepath.Join(toDir, "app"), } }, wantStacks: []string{}, expectedChanged: []string{"app", "new", "cache"}, }, { name: "Git filter - single reference (compared to HEAD)", filterQueries: func(fromRef, _ string) []string { return []string{"[" + fromRef + "]"} }, wantUnits: func(fromDir, toDir string) []string { return []string{ filepath.Join(fromDir, "cache"), filepath.Join(toDir, "app"), filepath.Join(toDir, "new"), } }, wantStacks: []string{}, expectedChanged: []string{"app", "new", "cache"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create initial components createUnit(t, tmpDir, "app", `# App unit`) createUnit(t, tmpDir, "db", `# DB unit`) createUnit(t, tmpDir, "cache", `# Cache unit`) commitChanges(t, runner, "Initial commit") // Modify app component err := os.WriteFile(filepath.Join(tmpDir, "app", "terragrunt.hcl"), []byte(` locals { modified = true } `), 0644) require.NoError(t, err) // Add new component createUnit(t, tmpDir, "new", `# New unit`) // Remove cache component err = os.RemoveAll(filepath.Join(tmpDir, "cache")) require.NoError(t, err) commitChanges(t, runner, "Changes: modified app, added new, removed cache") // Parse filter queries l := logger.CreateLogger() filterQueries := tt.filterQueries("HEAD~1", "HEAD") filters, err := filter.ParseFilterQueries(l, filterQueries) require.NoError(t, err) // Create worktrees w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, filters.UniqueGitFilters()) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, } discovery := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w). WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) // Filter results by type units := components.Filter(component.UnitKind).Paths() stacks := components.Filter(component.StackKind).Paths() worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] require.NotEmpty(t, worktreePair) wantUnits := tt.wantUnits(worktreePair.FromWorktree.Path, worktreePair.ToWorktree.Path) // Verify results assert.ElementsMatch(t, wantUnits, units, "Units mismatch for test: %s", tt.name) assert.ElementsMatch(t, tt.wantStacks, stacks, "Stacks mismatch for test: %s", tt.name) }) } } // TestWorktreePhase_Integration_FromSubdirectory tests that git filter discovery works correctly // when running from a subdirectory of the git root. This is a regression test for the bug where // paths were incorrectly duplicated (e.g., "basic/basic/basic-2" instead of "basic/basic-2"). func TestWorktreePhase_Integration_FromSubdirectory(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create subdirectory structure: basic/basic-1, basic/basic-2 basicDir := filepath.Join(tmpDir, "basic") basic1Dir := filepath.Join(basicDir, "basic-1") basic2Dir := filepath.Join(basicDir, "basic-2") // Also create a component outside the subdirectory otherDir := filepath.Join(tmpDir, "other") testDirs := []string{basic1Dir, basic2Dir, otherDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create initial files initialFiles := map[string]string{ filepath.Join(basic1Dir, "terragrunt.hcl"): ``, filepath.Join(basic2Dir, "terragrunt.hcl"): ``, filepath.Join(otherDir, "terragrunt.hcl"): ``, } for path, content := range initialFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } commitChanges(t, runner, "Initial commit") // Modify basic-2 component err := os.WriteFile(filepath.Join(basic2Dir, "terragrunt.hcl"), []byte(` locals { modified = true } `), 0644) require.NoError(t, err) commitChanges(t, runner, "Modified basic-2") // Now run discovery FROM THE SUBDIRECTORY (basic) l := logger.CreateLogger() // Parse filter with Git reference filters, err := filter.ParseFilterQueries(l, []string{"[HEAD~1]"}) require.NoError(t, err) // Create worktrees from the subdirectory w, err := worktrees.NewWorktrees(t.Context(), l, basicDir, filters.UniqueGitFilters()) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = basicDir opts.RootWorkingDir = basicDir discoveryContext := &component.DiscoveryContext{ WorkingDir: basicDir, } discovery := discovery.NewDiscovery(basicDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w). WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) // Filter results by type units := components.Filter(component.UnitKind).Paths() // With worktree-based execution, discovery runs directly in the worktree path worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] require.NotEmpty(t, worktreePair) expectedPath := filepath.Join(worktreePair.ToWorktree.Path, "basic", "basic-2") assert.ElementsMatch(t, []string{expectedPath}, units, "Should discover basic-2 with correct path when running from subdirectory") // Verify the path doesn't have duplicated directory names for _, unitPath := range units { assert.NotContains(t, unitPath, "basic"+string(filepath.Separator)+"basic"+string(filepath.Separator)+"basic-", "Path should not have duplicated directory names") } } // setupMultiCommitTestRepo creates a git repository with 4 commits for testing // git filter discovery from a subdirectory. Returns the basicDir (subdirectory). func setupMultiCommitTestRepo(t *testing.T) string { t.Helper() tmpDir, runner := setupGitRepo(t) // Create subdirectory structure: basic/basic-1, basic/basic-2, basic/basic-3 basicDir := filepath.Join(tmpDir, "basic") basic1Dir := filepath.Join(basicDir, "basic-1") basic2Dir := filepath.Join(basicDir, "basic-2") basic3Dir := filepath.Join(basicDir, "basic-3") // Also create components outside the subdirectory otherDir := filepath.Join(tmpDir, "other") anotherDir := filepath.Join(tmpDir, "another") testDirs := []string{basic1Dir, basic2Dir, basic3Dir, otherDir, anotherDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Commit 1: Initial state with all components initialFiles := map[string]string{ filepath.Join(basic1Dir, "terragrunt.hcl"): ``, filepath.Join(basic2Dir, "terragrunt.hcl"): ``, filepath.Join(basic3Dir, "terragrunt.hcl"): ``, filepath.Join(otherDir, "terragrunt.hcl"): ``, filepath.Join(anotherDir, "terragrunt.hcl"): ``, } for path, content := range initialFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } commitChanges(t, runner, "Initial commit") // Commit 2: Modify basic-1 and other (outside subdirectory) err := os.WriteFile(filepath.Join(basic1Dir, "terragrunt.hcl"), []byte(` locals { version = "v1" } `), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(otherDir, "terragrunt.hcl"), []byte(` locals { modified = true } `), 0644) require.NoError(t, err) commitChanges(t, runner, "Commit 2: modify basic-1 and other") // Commit 3: Modify basic-2 and another (outside subdirectory) err = os.WriteFile(filepath.Join(basic2Dir, "terragrunt.hcl"), []byte(` locals { version = "v2" } `), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(anotherDir, "terragrunt.hcl"), []byte(` locals { modified = true } `), 0644) require.NoError(t, err) commitChanges(t, runner, "Commit 3: modify basic-2 and another") // Commit 4: Modify basic-3 err = os.WriteFile(filepath.Join(basic3Dir, "terragrunt.hcl"), []byte(` locals { version = "v3" } `), 0644) require.NoError(t, err) commitChanges(t, runner, "Commit 4: modify basic-3") return basicDir } // TestWorktreePhase_Integration_NegatedGitGraphExpressions tests that negated Git+Graph expressions // work correctly. These are expressions where the negation wraps the Git expression: // - `![HEAD~1...HEAD]...` - Exclude changed components AND their dependencies // - `!...[HEAD~1...HEAD]` - Exclude changed components AND their dependents // // In worktree-based discovery, only changed components are initially discovered. // When a negated git+graph filter is used in combination with a positive git filter, // the filter semantics cause components matching the negation to be excluded. // // This validates the complete pipeline: worktree → graph → final evaluation with negation. func TestWorktreePhase_Integration_NegatedGitGraphExpressions(t *testing.T) { t.Parallel() tests := []struct { name string filterQueries func(fromRef, toRef string) []string wantUnits func(fromDir, toDir string) []string description string expectedChanged []string // which units are expected to be changed in the test setup }{ { name: "simple negated git expression excludes all changed", filterQueries: func(fromRef, toRef string) []string { // ![HEAD~1...HEAD] = Negated git expression excludes changed components // When combined with positive filter, negation takes precedence return []string{ "[" + fromRef + "..." + toRef + "]", // Include all changed "![" + fromRef + "..." + toRef + "]", // Exclude changed } }, wantUnits: func(_, _ string) []string { // Both filters apply: positive includes, negative excludes // Components matching negation are excluded from final result return []string{} }, description: "Positive and negative git filters - negation excludes all", expectedChanged: []string{"app", "db"}, }, { name: "negated git with dependency traversal in intersection", filterQueries: func(fromRef, toRef string) []string { // Use intersection to apply negated graph filter to git results // [HEAD~1...HEAD] | ![HEAD~1...HEAD]... = changed AND NOT (changed with deps) return []string{"[" + fromRef + "..." + toRef + "] | ![" + fromRef + "..." + toRef + "]..."} }, wantUnits: func(_, _ string) []string { // Intersection: component must match [changed] AND match ![changed]... // For any changed component, ![changed]... is false (negation of true) // So intersection is always empty return []string{} }, description: "Git filter intersected with negated git+deps - empty result", expectedChanged: []string{"app"}, }, { name: "negated git with dependent traversal in intersection", filterQueries: func(fromRef, toRef string) []string { // Use intersection: [HEAD~1...HEAD] | !...[HEAD~1...HEAD] return []string{"[" + fromRef + "..." + toRef + "] | !...[" + fromRef + "..." + toRef + "]"} }, wantUnits: func(_, _ string) []string { // Intersection: component must match [changed] AND match !...[changed] // For any changed component, !...[changed] is false // So intersection is always empty return []string{} }, description: "Git filter intersected with negated git+dependents - empty result", expectedChanged: []string{"vpc"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create dependency chain: app -> db -> vpc // Plus an unrelated component for verification vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") unrelatedDir := filepath.Join(tmpDir, "unrelated") testDirs := []string{vpcDir, dbDir, appDir, unrelatedDir} for _, dir := range testDirs { err := os.MkdirAll(dir, 0755) require.NoError(t, err) } // Create initial files with dependencies testFiles := map[string]string{ filepath.Join(appDir, "terragrunt.hcl"): ` dependency "db" { config_path = "../db" } `, filepath.Join(dbDir, "terragrunt.hcl"): ` dependency "vpc" { config_path = "../vpc" } `, filepath.Join(vpcDir, "terragrunt.hcl"): ``, filepath.Join(unrelatedDir, "terragrunt.hcl"): ``, } for path, content := range testFiles { err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) } commitChanges(t, runner, "Initial commit") // Modify the expected changed components based on test case for _, changed := range tt.expectedChanged { changedPath := filepath.Join(tmpDir, changed, "terragrunt.hcl") currentContent, err := os.ReadFile(changedPath) require.NoError(t, err) newContent := string(currentContent) + ` locals { modified = true } ` err = os.WriteFile(changedPath, []byte(newContent), 0644) require.NoError(t, err) } commitChanges(t, runner, "Modify components: "+tt.description) // Parse filter queries l := logger.CreateLogger() filterQueries := tt.filterQueries("HEAD~1", "HEAD") filters, err := filter.ParseFilterQueries(l, filterQueries) require.NoError(t, err) // Create worktrees w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, filters.UniqueGitFilters()) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, } discovery := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w). WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) // Filter results by type units := components.Filter(component.UnitKind).Paths() worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] require.NotEmpty(t, worktreePair) wantUnits := tt.wantUnits(worktreePair.FromWorktree.Path, worktreePair.ToWorktree.Path) // Verify results assert.ElementsMatch(t, wantUnits, units, "Units mismatch for test: %s\nDescription: %s", tt.name, tt.description) }) } } // TestWorktreePhase_Integration_FromSubdirectory_MultipleCommits tests git filter discovery // initiated from a subdirectory when comparing against multiple commits back (HEAD~2, HEAD~3). func TestWorktreePhase_Integration_FromSubdirectory_MultipleCommits(t *testing.T) { t.Parallel() tests := []struct { expectedUnitsFunc func(toWorktreePath string) []string name string gitRef string }{ { name: "HEAD~1 from subdirectory - only basic-3", gitRef: "HEAD~1", expectedUnitsFunc: func(toWorktreePath string) []string { return []string{filepath.Join(toWorktreePath, "basic", "basic-3")} }, }, { name: "HEAD~2 from subdirectory - basic-2 and basic-3, plus another", gitRef: "HEAD~2", expectedUnitsFunc: func(toWorktreePath string) []string { // With worktree-root discovery, we find all changed units including 'another' return []string{ filepath.Join(toWorktreePath, "another"), filepath.Join(toWorktreePath, "basic", "basic-2"), filepath.Join(toWorktreePath, "basic", "basic-3"), } }, }, { name: "HEAD~3 from subdirectory - basic-1, basic-2, basic-3, plus other and another", gitRef: "HEAD~3", expectedUnitsFunc: func(toWorktreePath string) []string { // With worktree-root discovery, we find all changed units return []string{ filepath.Join(toWorktreePath, "other"), filepath.Join(toWorktreePath, "another"), filepath.Join(toWorktreePath, "basic", "basic-1"), filepath.Join(toWorktreePath, "basic", "basic-2"), filepath.Join(toWorktreePath, "basic", "basic-3"), } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Each subtest creates its own git repository basicDir := setupMultiCommitTestRepo(t) l := logger.CreateLogger() // Parse filter with Git reference filters, err := filter.ParseFilterQueries(l, []string{"[" + tt.gitRef + "]"}) require.NoError(t, err) // Create worktrees from the subdirectory w, err := worktrees.NewWorktrees(t.Context(), l, basicDir, filters.UniqueGitFilters()) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = basicDir opts.RootWorkingDir = basicDir discoveryContext := &component.DiscoveryContext{ WorkingDir: basicDir, } discovery := discovery.NewDiscovery(basicDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w). WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) // Filter results by type units := components.Filter(component.UnitKind).Paths() // Get worktree pair for expected path calculation worktreePair := w.WorktreePairs["["+tt.gitRef+"...HEAD]"] require.NotEmpty(t, worktreePair) // Verify correct units are discovered expectedUnits := tt.expectedUnitsFunc(worktreePair.ToWorktree.Path) assert.ElementsMatch(t, expectedUnits, units, "Should discover correct units when running from subdirectory with %s", tt.gitRef) // Verify no path duplication for _, unitPath := range units { assert.NotContains(t, unitPath, "basic"+string(filepath.Separator)+"basic"+string(filepath.Separator)+"basic-", "Path should not have duplicated directory names") } }) } } // setupGitRepo creates a git repository with initial structure for integration tests. func setupGitRepo(t *testing.T) (string, *git.GitRunner) { t.Helper() tmpDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { if err := runner.GoCloseStorage(); err != nil { t.Logf("Error closing storage: %s", err) } }) return tmpDir, runner } // commitChanges stages all changes and commits with the given message. func commitChanges(t *testing.T, runner *git.GitRunner, message string) { t.Helper() err := runner.GoAdd(".") require.NoError(t, err) err = runner.GoCommit(message, &gogit.CommitOptions{ Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) } // createUnit creates a unit directory with terragrunt.hcl. func createUnit(t *testing.T, baseDir, unitName, content string) string { t.Helper() unitDir := filepath.Join(baseDir, unitName) err := os.MkdirAll(unitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte(content), 0644) require.NoError(t, err) return unitDir } // TestWorktreePhase_Integration_StackReadingChanges tests that changes to files referenced // via read_terragrunt_config() in a stack file trigger stack change detection, while changes // to unreferenced files in the same directory do not. func TestWorktreePhase_Integration_StackReadingChanges(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a catalog unit legacyUnitDir := filepath.Join(tmpDir, "catalog", "units", "legacy") err := os.MkdirAll(legacyUnitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "terragrunt.hcl"), []byte(`# Legacy unit`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "main.tf"), []byte(`# Intentionally empty`), 0644) require.NoError(t, err) commitChanges(t, runner, "Create catalog units") // Create a stack that references a sidecar file via read_terragrunt_config stackWithRefDir := filepath.Join(tmpDir, "live", "stack-with-ref") err = os.MkdirAll(stackWithRefDir, 0755) require.NoError(t, err) // Sidecar file referenced by the stack err = os.WriteFile(filepath.Join(stackWithRefDir, "config.hcl"), []byte(`inputs = { version = "v1" }`), 0644) require.NoError(t, err) stackWithRefContent := ` locals { config = read_terragrunt_config("config.hcl") } unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app" } ` err = os.WriteFile(filepath.Join(stackWithRefDir, "terragrunt.stack.hcl"), []byte(stackWithRefContent), 0644) require.NoError(t, err) // Create a stack WITHOUT read_terragrunt_config but with a file in same dir stackNoRefDir := filepath.Join(tmpDir, "live", "stack-no-ref") err = os.MkdirAll(stackNoRefDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackNoRefDir, "unrelated.hcl"), []byte(`# not referenced`), 0644) require.NoError(t, err) stackNoRefContent := ` unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app" } ` err = os.WriteFile(filepath.Join(stackNoRefDir, "terragrunt.stack.hcl"), []byte(stackNoRefContent), 0644) require.NoError(t, err) commitChanges(t, runner, "Create stacks with and without read_terragrunt_config") // Change only the sidecar files (not the stack files) err = os.WriteFile(filepath.Join(stackWithRefDir, "config.hcl"), []byte(`inputs = { version = "v2" }`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackNoRefDir, "unrelated.hcl"), []byte(`# still not referenced but modified`), 0644) require.NoError(t, err) commitChanges(t, runner, "Update sidecar files only") // Set up discovery with worktrees l := logger.CreateLogger() gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) // Generate stacks in worktrees opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]"}) require.NoError(t, parseErr) opts.Filters = parsedFilters opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.FilterFlag) require.NoError(t, err) err = generate.GenerateStacks(t.Context(), l, opts, w) require.NoError(t, err) // Run discovery discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: "plan", } disc := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w) filters := filter.Filters{} for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } disc = disc.WithFilters(filters) components, err := disc.Discover(t.Context(), l, opts) require.NoError(t, err) // Get worktree paths require.Contains(t, w.WorktreePairs, "[HEAD~1...HEAD]", "Worktree pair should exist") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] toWorktree := worktreePair.ToWorktree.Path // Collect component paths for debugging on failure componentPaths := make([]string, 0, len(components)) for _, c := range components { componentPaths = append(componentPaths, c.Path()) } // Verify: stack-with-ref should be discovered (config.hcl is referenced via read_terragrunt_config) stackWithRefRel, err := filepath.Rel(tmpDir, stackWithRefDir) require.NoError(t, err) expectedStackWithRef := filepath.Join(toWorktree, stackWithRefRel) foundStackWithRef := false for _, c := range components { if c.Path() == expectedStackWithRef { foundStackWithRef = true break } } assert.True(t, foundStackWithRef, "Stack with read_terragrunt_config reference should be discovered when sidecar changes; got: %v", componentPaths) // Verify: stack-no-ref should NOT be discovered (unrelated.hcl is not referenced) stackNoRefRel, err := filepath.Rel(tmpDir, stackNoRefDir) require.NoError(t, err) expectedStackNoRef := filepath.Join(toWorktree, stackNoRefRel) foundStackNoRef := false for _, c := range components { if c.Path() == expectedStackNoRef { foundStackNoRef = true break } } assert.False(t, foundStackNoRef, "Stack without read_terragrunt_config reference should NOT be discovered; got: %v", componentPaths) } // TestWorktreePhase_Integration_StackReadingDedup tests that when both the stack file itself // and a sidecar file referenced via read_terragrunt_config() change in the same commit, // the stack is discovered exactly once (no duplication from buildHandledStackDirs). func TestWorktreePhase_Integration_StackReadingDedup(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a catalog unit legacyUnitDir := filepath.Join(tmpDir, "catalog", "units", "legacy") err := os.MkdirAll(legacyUnitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "terragrunt.hcl"), []byte(`# Legacy unit`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "main.tf"), []byte(`# Intentionally empty`), 0644) require.NoError(t, err) commitChanges(t, runner, "Create catalog units") // Create a stack with read_terragrunt_config + sidecar stackDir := filepath.Join(tmpDir, "live", "dedup-stack") err = os.MkdirAll(stackDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackDir, "config.hcl"), []byte(`inputs = { version = "v1" }`), 0644) require.NoError(t, err) stackContent := ` locals { config = read_terragrunt_config("config.hcl") } unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app" } ` err = os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(stackContent), 0644) require.NoError(t, err) commitChanges(t, runner, "Create stack with read_terragrunt_config") // Change BOTH the stack file AND the sidecar file in the same commit updatedStackContent := ` locals { config = read_terragrunt_config("config.hcl") } unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app-v2" } ` err = os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(updatedStackContent), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(stackDir, "config.hcl"), []byte(`inputs = { version = "v2" }`), 0644) require.NoError(t, err) commitChanges(t, runner, "Update both stack file and sidecar") // Set up discovery l := logger.CreateLogger() gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]"}) require.NoError(t, parseErr) opts.Filters = parsedFilters opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.FilterFlag) require.NoError(t, err) err = generate.GenerateStacks(t.Context(), l, opts, w) require.NoError(t, err) // Run discovery discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: "plan", } disc := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w) filters := filter.Filters{} for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } disc = disc.WithFilters(filters) components, err := disc.Discover(t.Context(), l, opts) require.NoError(t, err) // Get worktree pair path require.Contains(t, w.WorktreePairs, "[HEAD~1...HEAD]", "Worktree pair should exist") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] toWorktree := worktreePair.ToWorktree.Path // Collect component paths componentPaths := make([]string, 0, len(components)) for _, c := range components { componentPaths = append(componentPaths, c.Path()) } // The stack should be discovered (stack file changed) stackRel, err := filepath.Rel(tmpDir, stackDir) require.NoError(t, err) expectedStackPath := filepath.Join(toWorktree, stackRel) // Verify no duplicate paths — dedup via buildHandledStackDirs should prevent // findStacksAffectedByReading from adding the stack again seen := make(map[string]int, len(components)) for _, c := range components { seen[c.Path()]++ } for p, count := range seen { assert.Equal(t, 1, count, "Component path %s appears %d times (expected 1); all: %v", p, count, componentPaths) } // Verify the stack itself is discovered _, foundStack := seen[expectedStackPath] assert.True(t, foundStack, "Stack %s should be discovered when both stack file and sidecar change; got: %v", expectedStackPath, componentPaths) } // TestWorktreePhase_Integration_StackReadingNestedPath tests that stacks referencing sidecar // files at nested or sibling paths (e.g., read_terragrunt_config("../../env/config.hcl")) // are correctly discovered when those files change. func TestWorktreePhase_Integration_StackReadingNestedPath(t *testing.T) { t.Parallel() tmpDir, runner := setupGitRepo(t) // Create a catalog unit legacyUnitDir := filepath.Join(tmpDir, "catalog", "units", "legacy") err := os.MkdirAll(legacyUnitDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "terragrunt.hcl"), []byte(`# Legacy unit`), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(legacyUnitDir, "main.tf"), []byte(`# Intentionally empty`), 0644) require.NoError(t, err) commitChanges(t, runner, "Create catalog units") // Create a sidecar config in a DIFFERENT directory tree than the stack envDir := filepath.Join(tmpDir, "env") err = os.MkdirAll(envDir, 0755) require.NoError(t, err) err = os.WriteFile(filepath.Join(envDir, "config.hcl"), []byte(`inputs = { version = "v1" }`), 0644) require.NoError(t, err) // Create a stack that references the sidecar via a nested/sibling path stackDir := filepath.Join(tmpDir, "live", "my-stack") err = os.MkdirAll(stackDir, 0755) require.NoError(t, err) stackContent := ` locals { config = read_terragrunt_config("../../env/config.hcl") } unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app" } ` err = os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(stackContent), 0644) require.NoError(t, err) // Create a stack WITHOUT a cross-directory reference (control) stackNoRefDir := filepath.Join(tmpDir, "live", "no-ref-stack") err = os.MkdirAll(stackNoRefDir, 0755) require.NoError(t, err) stackNoRefContent := ` unit "app" { source = "${get_repo_root()}/catalog/units/legacy" path = "app" } ` err = os.WriteFile(filepath.Join(stackNoRefDir, "terragrunt.stack.hcl"), []byte(stackNoRefContent), 0644) require.NoError(t, err) commitChanges(t, runner, "Create stacks and env config") // Change ONLY the sidecar file in the separate directory err = os.WriteFile(filepath.Join(envDir, "config.hcl"), []byte(`inputs = { version = "v2" }`), 0644) require.NoError(t, err) commitChanges(t, runner, "Update env config only") // Set up discovery l := logger.CreateLogger() gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")} w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]"}) require.NoError(t, parseErr) opts.Filters = parsedFilters opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.FilterFlag) require.NoError(t, err) err = generate.GenerateStacks(t.Context(), l, opts, w) require.NoError(t, err) // Run discovery discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: "plan", } disc := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w) filters := filter.Filters{} for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } disc = disc.WithFilters(filters) components, err := disc.Discover(t.Context(), l, opts) require.NoError(t, err) // Get worktree paths require.Contains(t, w.WorktreePairs, "[HEAD~1...HEAD]", "Worktree pair should exist") worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"] toWorktree := worktreePair.ToWorktree.Path // Collect component paths for debugging componentPaths := make([]string, 0, len(components)) for _, c := range components { componentPaths = append(componentPaths, c.Path()) } // Verify: stack with cross-directory read_terragrunt_config reference IS discovered stackRel, err := filepath.Rel(tmpDir, stackDir) require.NoError(t, err) expectedStack := filepath.Join(toWorktree, stackRel) foundStack := false for _, c := range components { if c.Path() == expectedStack { foundStack = true break } } assert.True(t, foundStack, "Stack with nested read_terragrunt_config reference should be discovered when sidecar changes; got: %v", componentPaths) // Verify: stack WITHOUT the reference should NOT be discovered stackNoRefRel, err := filepath.Rel(tmpDir, stackNoRefDir) require.NoError(t, err) expectedNoRef := filepath.Join(toWorktree, stackNoRefRel) foundNoRef := false for _, c := range components { if c.Path() == expectedNoRef { foundNoRef = true break } } assert.False(t, foundNoRef, "Stack without read_terragrunt_config reference should NOT be discovered; got: %v", componentPaths) } // runWorktreeDiscovery runs discovery with worktree phase enabled. func runWorktreeDiscovery( t *testing.T, tmpDir string, gitExpressions filter.GitExpressions, cmd string, args []string, ) (component.Components, *worktrees.Worktrees) { t.Helper() l := logger.CreateLogger() w, err := worktrees.NewWorktrees(t.Context(), l, tmpDir, gitExpressions) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l) require.NoError(t, cleanupErr) }) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir discoveryContext := &component.DiscoveryContext{ WorkingDir: tmpDir, Cmd: cmd, Args: args, } // Build filters from git expressions filters := make(filter.Filters, 0, len(gitExpressions)) for _, gitExpr := range gitExpressions { f := filter.NewFilter(gitExpr, gitExpr.String()) filters = append(filters, f) } discovery := discovery.NewDiscovery(tmpDir). WithDiscoveryContext(discoveryContext). WithWorktrees(w). WithFilters(filters) components, err := discovery.Discover(t.Context(), l, opts) require.NoError(t, err) return components, w } ================================================ FILE: internal/discovery/phase_worktree_test.go ================================================ package discovery_test import ( "os" "path/filepath" "slices" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestNewWorktreePhase tests the WorktreePhase constructor. func TestNewWorktreePhase(t *testing.T) { t.Parallel() tests := []struct { name string numWorkers int expectedNumWorkers int }{ { name: "positive workers", numWorkers: 4, expectedNumWorkers: 4, }, { name: "zero workers defaults to CPU count", numWorkers: 0, expectedNumWorkers: -1, // Will check > 0 }, { name: "negative workers defaults to CPU count", numWorkers: -1, expectedNumWorkers: -1, // Will check > 0 }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() phase := discovery.NewWorktreePhase(nil, tt.numWorkers) assert.NotNil(t, phase) assert.Equal(t, "worktree", phase.Name()) assert.Equal(t, discovery.PhaseWorktree, phase.Kind()) if tt.expectedNumWorkers > 0 { assert.Equal(t, tt.expectedNumWorkers, phase.NumWorkers()) } else { // When workers <= 0, it should default to runtime.NumCPU() assert.Positive(t, phase.NumWorkers()) } }) } } // TestGenerateDirSHA256 tests the SHA256 hash generation for directories. func TestGenerateDirSHA256(t *testing.T) { t.Parallel() t.Run("empty_directory_produces_consistent_hash", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() hash1, err := discovery.GenerateDirSHA256(tmpDir) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir) require.NoError(t, err) assert.Equal(t, hash1, hash2, "Same empty directory should produce same hash") }) t.Run("same_files_produce_same_hash", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() content := []byte("test content") require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "file.txt"), content, 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, "file.txt"), content, 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.Equal(t, hash1, hash2, "Directories with same files should produce same hash") }) t.Run("modified_file_produces_different_hash", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("content1"), 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("content2"), 0644)) hash2, err := discovery.GenerateDirSHA256(tmpDir) require.NoError(t, err) assert.NotEqual(t, hash1, hash2, "Modified file should produce different hash") }) t.Run("file_rename_produces_different_hash", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() content := []byte("same content") require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "original.txt"), content, 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, "renamed.txt"), content, 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.NotEqual(t, hash1, hash2, "File rename (different path) should produce different hash") }) t.Run("file_move_to_subdirectory_produces_different_hash", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() content := []byte("same content") require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "file.txt"), content, 0644)) subDir := filepath.Join(tmpDir2, "subdir") require.NoError(t, os.MkdirAll(subDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(subDir, "file.txt"), content, 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.NotEqual(t, hash1, hash2, "File move to subdirectory should produce different hash") }) t.Run("ignores_terragrunt_stack_manifest", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() content := []byte("test content") require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "file.txt"), content, 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, "file.txt"), content, 0644)) // Add .terragrunt-stack-manifest only to tmpDir2 manifestContent := []byte("/path/to/something\n/another/path") require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, ".terragrunt-stack-manifest"), manifestContent, 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.Equal(t, hash1, hash2, ".terragrunt-stack-manifest should be ignored in hash calculation") }) t.Run("multiple_files_order_independent", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() // Create files in different order but same content require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "a.txt"), []byte("a"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir1, "b.txt"), []byte("b"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, "b.txt"), []byte("b"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir2, "a.txt"), []byte("a"), 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.Equal(t, hash1, hash2, "File creation order should not affect hash") }) t.Run("nonexistent_directory_returns_error", func(t *testing.T) { t.Parallel() _, err := discovery.GenerateDirSHA256("/nonexistent/path/to/directory") require.Error(t, err) }) t.Run("nested_directories_included", func(t *testing.T) { t.Parallel() tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() // Create nested structure in both subDir1 := filepath.Join(tmpDir1, "sub", "nested") subDir2 := filepath.Join(tmpDir2, "sub", "nested") require.NoError(t, os.MkdirAll(subDir1, 0755)) require.NoError(t, os.MkdirAll(subDir2, 0755)) content := []byte("nested content") require.NoError(t, os.WriteFile(filepath.Join(subDir1, "file.txt"), content, 0644)) require.NoError(t, os.WriteFile(filepath.Join(subDir2, "file.txt"), content, 0644)) hash1, err := discovery.GenerateDirSHA256(tmpDir1) require.NoError(t, err) hash2, err := discovery.GenerateDirSHA256(tmpDir2) require.NoError(t, err) assert.Equal(t, hash1, hash2, "Nested directories with same structure should produce same hash") }) } // TestMatchComponentPairs tests the component pair matching logic. func TestMatchComponentPairs(t *testing.T) { t.Parallel() t.Run("matches_by_relative_path", func(t *testing.T) { t.Parallel() fromComponents := component.Components{ createTestComponent("/worktree-from/app", "/worktree-from"), createTestComponent("/worktree-from/db", "/worktree-from"), } toComponents := component.Components{ createTestComponent("/worktree-to/app", "/worktree-to"), createTestComponent("/worktree-to/db", "/worktree-to"), } pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Len(t, pairs, 2, "Should match 2 component pairs") // Verify the pairs are correctly matched paths := make(map[string]bool) for _, p := range pairs { fromSuffix := getRelativePath(p.FromComponent) toSuffix := getRelativePath(p.ToComponent) assert.Equal(t, fromSuffix, toSuffix, "Matched components should have same relative paths") paths[fromSuffix] = true } assert.True(t, paths["/app"], "Should have matched app") assert.True(t, paths["/db"], "Should have matched db") }) t.Run("handles_added_only_components", func(t *testing.T) { t.Parallel() fromComponents := component.Components{} toComponents := component.Components{ createTestComponent("/worktree-to/new-unit", "/worktree-to"), } pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Empty(t, pairs, "Added-only components should not produce pairs") }) t.Run("handles_removed_only_components", func(t *testing.T) { t.Parallel() fromComponents := component.Components{ createTestComponent("/worktree-from/removed-unit", "/worktree-from"), } toComponents := component.Components{} pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Empty(t, pairs, "Removed-only components should not produce pairs") }) t.Run("handles_renamed_components_no_match", func(t *testing.T) { t.Parallel() fromComponents := component.Components{ createTestComponent("/worktree-from/old-name", "/worktree-from"), } toComponents := component.Components{ createTestComponent("/worktree-to/new-name", "/worktree-to"), } pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Empty(t, pairs, "Renamed components (different paths) should not match") }) t.Run("handles_mixed_scenario", func(t *testing.T) { t.Parallel() fromComponents := component.Components{ createTestComponent("/worktree-from/shared", "/worktree-from"), createTestComponent("/worktree-from/removed", "/worktree-from"), } toComponents := component.Components{ createTestComponent("/worktree-to/shared", "/worktree-to"), createTestComponent("/worktree-to/added", "/worktree-to"), } pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Len(t, pairs, 1, "Should only match the shared component") assert.Equal(t, "/shared", getRelativePath(pairs[0].FromComponent)) assert.Equal(t, "/shared", getRelativePath(pairs[0].ToComponent)) }) t.Run("handles_empty_inputs", func(t *testing.T) { t.Parallel() pairs, err := discovery.MatchComponentPairs(component.Components{}, component.Components{}) require.NoError(t, err) assert.Empty(t, pairs, "Empty inputs should produce empty pairs") }) t.Run("handles_nested_paths", func(t *testing.T) { t.Parallel() fromComponents := component.Components{ createTestComponent("/worktree-from/apps/frontend", "/worktree-from"), createTestComponent("/worktree-from/apps/backend", "/worktree-from"), } toComponents := component.Components{ createTestComponent("/worktree-to/apps/frontend", "/worktree-to"), createTestComponent("/worktree-to/apps/backend", "/worktree-to"), } pairs, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.NoError(t, err) assert.Len(t, pairs, 2, "Should match 2 nested component pairs") }) t.Run("returns_error_for_nil_discovery_context", func(t *testing.T) { t.Parallel() nilCtxComponent := component.NewUnit("/some/path") nilCtxComponent.SetDiscoveryContext(nil) fromComponents := component.Components{nilCtxComponent} toComponents := component.Components{} _, err := discovery.MatchComponentPairs(fromComponents, toComponents) require.Error(t, err) var missingCtxErr discovery.MissingDiscoveryContextError require.ErrorAs(t, err, &missingCtxErr) assert.Equal(t, "/some/path", missingCtxErr.ComponentPath) }) } // TestTranslateDiscoveryContextArgsForWorktree tests the command argument translation for worktrees. func TestTranslateDiscoveryContextArgsForWorktree(t *testing.T) { t.Parallel() tests := []struct { name string cmd string args []string kind discovery.WorktreeKind expectError bool expectDestroyArg bool }{ // fromWorktree cases - should add -destroy for plan/apply { name: "from_worktree_plan_adds_destroy", cmd: "plan", args: []string{}, kind: discovery.FromWorktreeKind, expectError: false, expectDestroyArg: true, }, { name: "from_worktree_apply_adds_destroy", cmd: "apply", args: []string{}, kind: discovery.FromWorktreeKind, expectError: false, expectDestroyArg: true, }, { name: "from_worktree_plan_with_other_args_adds_destroy", cmd: "plan", args: []string{"-out", "plan.out"}, kind: discovery.FromWorktreeKind, expectError: false, expectDestroyArg: true, }, { name: "from_worktree_plan_with_destroy_already_present_errors", cmd: "plan", args: []string{"-destroy"}, kind: discovery.FromWorktreeKind, expectError: true, expectDestroyArg: false, }, { name: "from_worktree_empty_command_allowed", cmd: "", args: []string{}, kind: discovery.FromWorktreeKind, expectError: false, expectDestroyArg: false, }, { name: "from_worktree_unsupported_command_errors", cmd: "destroy", args: []string{}, kind: discovery.FromWorktreeKind, expectError: true, expectDestroyArg: false, }, { name: "from_worktree_output_command_errors", cmd: "output", args: []string{}, kind: discovery.FromWorktreeKind, expectError: true, expectDestroyArg: false, }, // toWorktree cases - should NOT add -destroy for plan/apply { name: "to_worktree_plan_no_destroy", cmd: "plan", args: []string{}, kind: discovery.ToWorktreeKind, expectError: false, expectDestroyArg: false, }, { name: "to_worktree_apply_no_destroy", cmd: "apply", args: []string{}, kind: discovery.ToWorktreeKind, expectError: false, expectDestroyArg: false, }, { name: "to_worktree_plan_with_other_args", cmd: "plan", args: []string{"-out", "plan.out"}, kind: discovery.ToWorktreeKind, expectError: false, expectDestroyArg: false, }, { name: "to_worktree_plan_with_destroy_already_present_errors", cmd: "plan", args: []string{"-destroy"}, kind: discovery.ToWorktreeKind, expectError: true, expectDestroyArg: false, }, { name: "to_worktree_empty_command_allowed", cmd: "", args: []string{}, kind: discovery.ToWorktreeKind, expectError: false, expectDestroyArg: false, }, { name: "to_worktree_unsupported_command_errors", cmd: "destroy", args: []string{}, kind: discovery.ToWorktreeKind, expectError: true, expectDestroyArg: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() dc := &component.DiscoveryContext{ Cmd: tt.cmd, Args: tt.args, } result, err := discovery.TranslateDiscoveryContextArgsForWorktree(dc, tt.kind) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), "Git-based filtering is not supported") return } require.NoError(t, err) require.NotNil(t, result) if tt.expectDestroyArg { assert.Contains(t, result.Args, "-destroy", "Expected -destroy flag for %s command in from worktree", tt.cmd) } else if tt.cmd == "plan" || tt.cmd == "apply" { // For to worktrees, verify -destroy is not added assert.False(t, slices.Contains(result.Args, "-destroy"), "Did not expect -destroy flag for %s command in to worktree", tt.cmd) } }) } } // TestWorktreeKind tests the worktreeKind constants. func TestWorktreeKind(t *testing.T) { t.Parallel() assert.Equal(t, discovery.FromWorktreeKind, discovery.WorktreeKind(0)) assert.Equal(t, discovery.ToWorktreeKind, discovery.WorktreeKind(1)) assert.NotEqual(t, discovery.FromWorktreeKind, discovery.ToWorktreeKind) } // Helper function to create a test component with discovery context. func createTestComponent(path, workingDir string) component.Component { c := component.NewUnit(path) c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: workingDir, }) return c } // Helper function to get the relative path of a component. func getRelativePath(c component.Component) string { dc := c.DiscoveryContext() if dc == nil { return c.Path() } rel := c.Path()[len(dc.WorkingDir):] if rel == "" { return "/" } return filepath.Clean(rel) } ================================================ FILE: internal/discovery/types.go ================================================ package discovery import ( "context" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Type aliases for filter package types used throughout discovery. // These provide backward compatibility and shorter type names within the discovery package. type ( // ClassificationStatus is an alias for filter.ClassificationStatus. ClassificationStatus = filter.ClassificationStatus // CandidacyReason is an alias for filter.CandidacyReason. CandidacyReason = filter.CandidacyReason // GraphExpressionInfo is an alias for filter.GraphExpressionInfo. GraphExpressionInfo = filter.GraphExpressionInfo ) // Status constants are aliases for filter package constants. const ( StatusDiscovered = filter.StatusDiscovered StatusCandidate = filter.StatusCandidate StatusExcluded = filter.StatusExcluded ) // CandidacyReason constants are aliases for filter package constants. const ( CandidacyReasonNone = filter.CandidacyReasonNone CandidacyReasonGraphTarget = filter.CandidacyReasonGraphTarget CandidacyReasonRequiresParse = filter.CandidacyReasonRequiresParse CandidacyReasonPotentialDependent = filter.CandidacyReasonPotentialDependent ) // PhaseKind identifies the type of discovery phase. type PhaseKind int const ( // PhaseFilesystem walks directories to find terragrunt configurations. PhaseFilesystem PhaseKind = iota // PhaseWorktree discovers components in Git worktrees (concurrent with Filesystem). PhaseWorktree // PhaseParse parses HCL configurations for filter evaluation. PhaseParse // PhaseGraph traverses dependency/dependent relationships. PhaseGraph // PhaseRelationship builds dependency graph for orphan components. PhaseRelationship // PhaseFinal applies final filter evaluation and cycle checking. PhaseFinal ) // String returns a string representation of the PhaseKind. func (pk PhaseKind) String() string { switch pk { case PhaseFilesystem: return "filesystem" case PhaseWorktree: return "worktree" case PhaseParse: return "parse" case PhaseGraph: return "graph" case PhaseRelationship: return "relationship" case PhaseFinal: return "final" default: return "unknown" } } // DiscoveryResult represents a discovered or candidate component with metadata. type DiscoveryResult struct { // Component is the discovered Terragrunt component. Component component.Component // Status indicates whether this is a definite discovery, candidate, or excluded. Status ClassificationStatus // Reason explains why the component is a candidate (only meaningful when Status == StatusCandidate). Reason CandidacyReason // Phase indicates which phase produced this result. Phase PhaseKind // GraphExpressionIndex is the index of the graph expression that matched (for candidates). // This is used during the graph phase to determine how to traverse. GraphExpressionIndex int } // PhaseResults contains the results from running a discovery phase. // It provides thread-safe methods for collecting results during concurrent processing. type PhaseResults struct { // Discovered contains components definitively included in results. Discovered []DiscoveryResult // Candidates contains components that might be included pending further evaluation. Candidates []DiscoveryResult // mu protects concurrent access to Discovered and Candidates. mu sync.Mutex } // NewPhaseResults creates a new PhaseResults. func NewPhaseResults() *PhaseResults { return &PhaseResults{} } // AddDiscovered adds a discovered result to the results in a thread-safe manner. func (pr *PhaseResults) AddDiscovered(result DiscoveryResult) { pr.mu.Lock() defer pr.mu.Unlock() pr.Discovered = append(pr.Discovered, result) } // AddCandidate adds a candidate result to the results in a thread-safe manner. func (pr *PhaseResults) AddCandidate(result DiscoveryResult) { pr.mu.Lock() defer pr.mu.Unlock() pr.Candidates = append(pr.Candidates, result) } // PhaseInput provides input data to a discovery phase. type PhaseInput struct { Opts *options.TerragruntOptions Classifier *filter.Classifier Discovery *Discovery Components component.Components Candidates []DiscoveryResult } // Phase defines the interface for a discovery phase. type Phase interface { // Name returns the human-readable name of the phase. Name() string // Kind returns the PhaseKind identifier. Kind() PhaseKind // Run executes the phase with the given input and returns the result and any error. Run(ctx context.Context, l log.Logger, input *PhaseInput) (*PhaseResults, error) } // Discovery is the main configuration for discovery. type Discovery struct { // discoveryContext is the context in which the discovery is happening. discoveryContext *component.DiscoveryContext // worktrees is the worktrees created for Git-based filters. worktrees *worktrees.Worktrees // workingDir is the directory to search for Terragrunt configurations. workingDir string // gitRoot is the git repository root, used as boundary for dependent discovery. gitRoot string // graphTarget is the target path for graph filtering (prune to target + dependents). graphTarget string // configFilenames is the list of config filenames to discover. If nil, defaults are used. configFilenames []string // parserOptions are custom HCL parser options to use when parsing during discovery. parserOptions []hclparse.Option // filters contains filter queries for component selection. filters filter.Filters // classifier categorizes filter expressions for efficient evaluation. classifier *filter.Classifier // gitExpressions contains Git filter expressions that require worktree discovery. gitExpressions filter.GitExpressions // maxDependencyDepth is the maximum depth of the dependency tree to discover. maxDependencyDepth int // numWorkers determines the number of concurrent workers for discovery operations. numWorkers int // noHidden determines whether to detect configurations in hidden directories. noHidden bool // requiresParse is true when the discovery requires parsing Terragrunt configurations. requiresParse bool // parseExclude determines whether to parse exclude configurations. parseExclude bool // parseIncludes determines whether to parse for include configurations. parseIncludes bool // readFiles determines whether to parse for reading files. readFiles bool // suppressParseErrors determines whether to suppress errors when parsing Terragrunt configurations. suppressParseErrors bool // breakCycles determines whether to break cycles in the dependency graph if any exist. breakCycles bool // excludeByDefault determines whether to exclude configurations by default (triggered by include flags). excludeByDefault bool // discoverRelationships determines whether to run relationship discovery. discoverRelationships bool } ================================================ FILE: internal/engine/engine.go ================================================ // Package engine provides the pluggable IaC engine for Terragrunt. package engine import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" logwriter "github.com/gruntwork-io/terragrunt/pkg/log/writer" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/github" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/hashicorp/go-hclog" "google.golang.org/grpc/credentials/insecure" "github.com/gruntwork-io/terragrunt-engine-go/engine" "github.com/gruntwork-io/terragrunt-engine-go/proto" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/hashicorp/go-plugin" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" ) const ( engineVersion = 1 engineCookieKey = "engine" engineCookieValue = "terragrunt" defaultCacheDir = ".cache" defaultEngineCachePath = "terragrunt/plugins/iac-engine" prefixTrim = "terragrunt-" fileNameFormat = "terragrunt-iac-%s_%s_%s_%s_%s" checksumFileNameFormat = "terragrunt-iac-%s_%s_%s_SHA256SUMS" engineLogLevelEnv = "TG_ENGINE_LOG_LEVEL" defaultEngineRepoRoot = "github.com/" terraformCommandContextKey engineClientsKey = iota locksContextKey engineLocksKey = iota latestVersionsContextKey engineLocksKey = iota dirPerm = 0755 errMsgEngineClientsFetch = "failed to fetch engine clients from context" errMsgEngineClientsCast = "failed to cast engine clients from context" errMsgVersionsCacheFetch = "failed to fetch engine versions cache from context" errMsgVersionsCacheCast = "failed to cast engine versions cache from context" ) type ( engineClientsKey byte engineLocksKey byte ) type ExecutionOptions struct { Writers writer.Writers EngineOptions *EngineOptions EngineConfig *EngineConfig Env map[string]string WorkingDir string RootWorkingDir string Command string Args []string Headless bool ForwardTFStdout bool SuppressStdout bool AllocatePseudoTty bool } type engineInstance struct { engineClient *proto.EngineClient client *plugin.Client execOptions *ExecutionOptions } // Run executes the given command with the experimental engine. func Run( ctx context.Context, l log.Logger, execOptions *ExecutionOptions, ) (*util.CmdOutput, error) { engineClients, err := engineClientsFromContext(ctx) if err != nil { return nil, errors.New(err) } workingDir := execOptions.WorkingDir instance, found := engineClients.Load(workingDir) // initialize engine for working directory if !found { // download engine if not available if err = downloadEngine(ctx, l, execOptions); err != nil { return nil, errors.New(err) } terragruntEngine, client, createEngineErr := createEngine(ctx, l, execOptions) if createEngineErr != nil { return nil, errors.New(createEngineErr) } engineClients.Store(workingDir, &engineInstance{ engineClient: terragruntEngine, client: client, execOptions: execOptions, }) instance, _ = engineClients.Load(workingDir) if err = initialize(ctx, l, execOptions, terragruntEngine); err != nil { return nil, errors.New(err) } } engInst, ok := instance.(*engineInstance) if !ok { return nil, errors.Errorf("failed to fetch engine instance %s", workingDir) } terragruntEngine := engInst.engineClient output, err := invoke(ctx, l, execOptions, terragruntEngine) if err != nil { return nil, errors.New(err) } return output, nil } // WithEngineValues add to context default values for engine. func WithEngineValues(ctx context.Context) context.Context { ctx = context.WithValue(ctx, terraformCommandContextKey, &sync.Map{}) ctx = context.WithValue(ctx, locksContextKey, util.NewKeyLocks()) ctx = context.WithValue(ctx, latestVersionsContextKey, cache.NewCache[string]("engineVersions")) return ctx } // downloadEngine downloads the engine for the given options. func downloadEngine(ctx context.Context, l log.Logger, execOptions *ExecutionOptions) error { e := execOptions.EngineConfig if e == nil { return nil } if util.FileExists(e.Source) { // if source is a file, no need to download, exit return nil } // If source is empty, we cannot download the engine // This indicates an engine block was configured but source was not provided if e.Source == "" { return errors.Errorf( "engine block is configured but source is empty. Please provide an engine source or remove the engine block", ) } // identify engine version if not specified if len(e.Version) == 0 { if !strings.Contains(e.Source, "://") { tag, err := lastReleaseVersion(ctx, execOptions) if err != nil { return errors.New(err) } e.Version = tag } } path, err := engineDir(execOptions) if err != nil { return errors.New(err) } if ensureErr := util.EnsureDirectory(path); ensureErr != nil { return errors.New(ensureErr) } localEngineFile := filepath.Join(path, engineFileName(e)) // lock downloading process for only one instance locks, err := downloadLocksFromContext(ctx) if err != nil { return errors.New(err) } // locking by file where engine is downloaded // however, it will not help in case of multiple parallel Terragrunt runs locks.Lock(localEngineFile) defer locks.Unlock(localEngineFile) if util.FileExists(localEngineFile) { return nil } downloadFile := filepath.Join(path, enginePackageName(e)) // Prepare download assets assets := &github.ReleaseAssets{ Repository: e.Source, Version: e.Version, PackageFile: downloadFile, } var checksumFile, checksumSigFile string // Only add checksum files for GitHub releases (not direct URLs) if !strings.Contains(e.Source, "://") { checksumFile = filepath.Join(path, engineChecksumName(e)) checksumSigFile = filepath.Join(path, engineChecksumSigName(e)) assets.ChecksumFile = checksumFile assets.ChecksumSigFile = checksumSigFile } // Create download client and download assets downloadClient := github.NewGitHubReleasesDownloadClient(github.WithLogger(l)) result, err := downloadClient.DownloadReleaseAssets(ctx, assets) if err != nil { return errors.Errorf("failed to download engine assets: %w", err) } // Update file paths from result downloadFile = result.PackageFile checksumFile = result.ChecksumFile checksumSigFile = result.ChecksumSigFile if !execOptions.EngineOptions.SkipChecksumCheck && checksumFile != "" && checksumSigFile != "" { l.Infof("Verifying checksum for %s", downloadFile) if err := verifyFile(downloadFile, checksumFile, checksumSigFile); err != nil { return errors.New(err) } } else { l.Warnf("Skipping verification for %s", downloadFile) } if err := extractArchive(l, downloadFile, localEngineFile); err != nil { return errors.New(err) } l.Infof("Engine available as %s", path) return nil } func lastReleaseVersion(ctx context.Context, opts *ExecutionOptions) (string, error) { repository := strings.TrimPrefix(opts.EngineConfig.Source, defaultEngineRepoRoot) versionCache, err := engineVersionsCacheFromContext(ctx) if err != nil { return "", errors.New(err) } cacheKey := "github_release_" + repository if val, found := versionCache.Get(ctx, cacheKey); found { return val, nil } githubClient := github.NewGitHubAPIClient(github.WithGithubComDefaultAuth()) tag, err := githubClient.GetLatestReleaseTag(ctx, repository) if err != nil { return "", errors.Errorf("failed to get latest release for repository %s: %w", repository, err) } versionCache.Put(ctx, cacheKey, tag) return tag, nil } func extractArchive(l log.Logger, downloadFile string, engineFile string) error { if !isArchiveByHeader(l, downloadFile) { l.Info("Downloaded file is not an archive, no extraction needed") // move file directly if it is not an archive if err := os.Rename(downloadFile, engineFile); err != nil { return errors.New(err) } return nil } // extract package and process files path := filepath.Dir(engineFile) tempDir, err := os.MkdirTemp(path, "temp-") if err != nil { return errors.New(err) } defer func() { if err = os.RemoveAll(tempDir); err != nil { l.Warnf("Failed to clean temp dir %s: %v", tempDir, err) } }() // extract archive if err = extract(l, downloadFile, tempDir); err != nil { return errors.New(err) } // process files files, err := os.ReadDir(tempDir) if err != nil { return errors.New(err) } l.Infof("Engine extracted to %s", path) if len(files) == 1 && !files[0].IsDir() { // handle case where archive contains a single file, most of the cases singleFile := filepath.Join(tempDir, files[0].Name()) if err := os.Rename(singleFile, engineFile); err != nil { return errors.New(err) } return nil } // Move all files to the engine directory for _, file := range files { srcPath := filepath.Join(tempDir, file.Name()) dstPath := filepath.Join(path, file.Name()) if err := os.Rename(srcPath, dstPath); err != nil { return errors.New(err) } } return nil } // engineDir returns the directory path where engine files are stored. func engineDir(opts *ExecutionOptions) (string, error) { engine := opts.EngineConfig if util.FileExists(engine.Source) { return filepath.Dir(engine.Source), nil } cacheDir := opts.EngineOptions.CachePath if len(cacheDir) == 0 { homeDir, err := os.UserHomeDir() if err != nil { return "", errors.New(err) } cacheDir = filepath.Join(homeDir, defaultCacheDir) } platform := runtime.GOOS arch := runtime.GOARCH return filepath.Join(cacheDir, defaultEngineCachePath, engine.Type, engine.Version, platform, arch), nil } // engineFileName returns the file name for the engine. func engineFileName(e *EngineConfig) string { engineName := filepath.Base(e.Source) if util.FileExists(e.Source) { // return file name if source is absolute path return engineName } platform := runtime.GOOS arch := runtime.GOARCH engineName = strings.TrimPrefix(engineName, prefixTrim) return fmt.Sprintf(fileNameFormat, engineName, e.Type, e.Version, platform, arch) } // engineChecksumName returns the file name of engine checksum file func engineChecksumName(e *EngineConfig) string { engineName := filepath.Base(e.Source) engineName = strings.TrimPrefix(engineName, prefixTrim) return fmt.Sprintf(checksumFileNameFormat, engineName, e.Type, e.Version) } // engineChecksumSigName returns the file name of engine checksum file signature func engineChecksumSigName(e *EngineConfig) string { return engineChecksumName(e) + ".sig" } // enginePackageName returns the package name for the engine. func enginePackageName(e *EngineConfig) string { return engineFileName(e) + ".zip" } // isArchiveByHeader checks if a file is an archive by examining its header. func isArchiveByHeader(l log.Logger, filePath string) bool { archiveType, err := detectFileType(l, filePath) return err == nil && archiveType != "" } // engineClientsFromContext returns the engine clients map from the context. func engineClientsFromContext(ctx context.Context) (*sync.Map, error) { val := ctx.Value(terraformCommandContextKey) if val == nil { return nil, errors.New(errMsgEngineClientsFetch) } result, ok := val.(*sync.Map) if !ok { return nil, errors.New(errMsgEngineClientsCast) } return result, nil } // downloadLocksFromContext returns the locks map from the context. func downloadLocksFromContext(ctx context.Context) (*util.KeyLocks, error) { val := ctx.Value(locksContextKey) if val == nil { return nil, errors.New(errMsgEngineClientsFetch) } result, ok := val.(*util.KeyLocks) if !ok { return nil, errors.New(errMsgEngineClientsCast) } return result, nil } func engineVersionsCacheFromContext(ctx context.Context) (*cache.Cache[string], error) { val := ctx.Value(latestVersionsContextKey) if val == nil { return nil, errors.New(errMsgVersionsCacheFetch) } result, ok := val.(*cache.Cache[string]) if !ok { return nil, errors.New(errMsgVersionsCacheCast) } return result, nil } const ( gracefulExitTimeout = 5 * time.Second pluginExitPollInterval = 50 * time.Millisecond ) // Shutdown shuts down the experimental engine. func Shutdown(ctx context.Context, l log.Logger, experiments experiment.Experiments, noEngine bool) error { if !experiments.Evaluate(experiment.IacEngine) || noEngine { return nil } // iterate over all engine instances and shutdown engineClients, err := engineClientsFromContext(ctx) if err != nil { return errors.New(err) } engineClients.Range(func(key, value any) bool { instance := value.(*engineInstance) l.Debugf("Shutting down engine for %s", instance.execOptions.WorkingDir) // We use without cancel here to ensure that the shutdown isn't cancelled by the main context, // like it is in the RunCommandWithOutput function. This ensures that we don't cancel the shutdown // when the command is cancelled. if err := shutdown( context.WithoutCancel(ctx), l, instance.execOptions, instance.engineClient, ); err != nil { l.Errorf("Error shutting down engine: %v", err) } // Wait for plugin to exit gracefully before force-killing. // The shutdown RPC has already told the plugin to exit, so it should // be cleaning up and exiting on its own. Give it time to finish. if !waitForPluginExit(instance.client, gracefulExitTimeout) { l.Debugf("Plugin did not exit gracefully within timeout, force killing") instance.client.Kill() } return true }) return nil } // waitForPluginExit waits for the plugin process to exit, returning true if it exited // within the timeout, false otherwise. func waitForPluginExit(client *plugin.Client, timeout time.Duration) bool { done := make(chan struct{}) go func() { // Client.Exited() returns true when the plugin process has exited for !client.Exited() { time.Sleep(pluginExitPollInterval) } close(done) }() select { case <-done: return true case <-time.After(timeout): return false } } // logEngineMessage logs a message from the engine at the appropriate log level. func logEngineMessage(l log.Logger, logLevel proto.LogLevel, content string) { switch logLevel { case proto.LogLevel_LOG_LEVEL_DEBUG: l.Debug(content) case proto.LogLevel_LOG_LEVEL_INFO: l.Info(content) case proto.LogLevel_LOG_LEVEL_WARN: l.Warn(content) case proto.LogLevel_LOG_LEVEL_ERROR: l.Error(content) case proto.LogLevel_LOG_LEVEL_UNSPECIFIED: // Treat unspecified as debug level l.Debug(content) } } // createEngine create engine for working directory func createEngine( ctx context.Context, l log.Logger, execOptions *ExecutionOptions, ) (*proto.EngineClient, *plugin.Client, error) { if execOptions.EngineConfig == nil { return nil, nil, errors.Errorf("engine options are nil") } // If source is empty, we cannot determine the engine file path if execOptions.EngineConfig.Source == "" { return nil, nil, errors.Errorf("engine source is empty, cannot create engine") } path, err := engineDir(execOptions) if err != nil { return nil, nil, errors.New(err) } localEnginePath := filepath.Join(path, engineFileName(execOptions.EngineConfig)) localChecksumFile := filepath.Join(path, engineChecksumName(execOptions.EngineConfig)) localChecksumSigFile := filepath.Join(path, engineChecksumSigName(execOptions.EngineConfig)) // validate engine before loading if verification is not disabled skipCheck := execOptions.EngineOptions.SkipChecksumCheck if !skipCheck && util.FileExists(localEnginePath) && util.FileExists(localChecksumFile) && util.FileExists(localChecksumSigFile) { if err = verifyFile(localEnginePath, localChecksumFile, localChecksumSigFile); err != nil { return nil, nil, errors.New(err) } } else { l.Warnf("Skipping verification for %s", localEnginePath) } l.Debugf("Creating engine %s", localEnginePath) engineLogLevel := execOptions.EngineOptions.LogLevel if len(engineLogLevel) == 0 { engineLogLevel = hclog.Warn.String() // update log level if it is different from info if l.Level() != log.InfoLevel { engineLogLevel = l.Level().String() } // turn off log formatting if disabled for Terragrunt if l.Formatter().DisabledOutput() { engineLogLevel = hclog.Off.String() } } logger := hclog.NewInterceptLogger(&hclog.LoggerOptions{ Level: hclog.LevelFromString(engineLogLevel), Output: l.Writer(), }) // We use without cancel here to ensure that the plugin isn't killed when the main context is cancelled, // like it is in the RunCommandWithOutput function. This ensures that we don't cancel the shutdown // when the command is cancelled. cmd := exec.CommandContext( context.WithoutCancel(ctx), localEnginePath, ) cmd.Cancel = func() error { if cmd.Process == nil { return nil } if sig := signal.SignalFromContext(ctx); sig != nil { return cmd.Process.Signal(sig) } return cmd.Process.Signal(os.Kill) } // pass log level to engine cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", engineLogLevelEnv, engineLogLevel)) client := plugin.NewClient(&plugin.ClientConfig{ Logger: logger, HandshakeConfig: plugin.HandshakeConfig{ ProtocolVersion: engineVersion, MagicCookieKey: engineCookieKey, MagicCookieValue: engineCookieValue, }, Plugins: map[string]plugin.Plugin{ "plugin": &engine.TerragruntGRPCEngine{}, }, Cmd: cmd, GRPCDialOptions: []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), }, AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, }) rpcClient, err := client.Client() if err != nil { return nil, nil, errors.New(err) } rawClient, err := rpcClient.Dispense("plugin") if err != nil { return nil, nil, errors.New(err) } terragruntEngine := rawClient.(proto.EngineClient) return &terragruntEngine, client, nil } // invoke engine for working directory func invoke(ctx context.Context, l log.Logger, runOptions *ExecutionOptions, client *proto.EngineClient) (*util.CmdOutput, error) { l = l.WithField(placeholders.TFPathKeyName, "engine") meta, err := ConvertMetaToProtobuf(runOptions.EngineConfig.Meta) if err != nil { return nil, errors.New(err) } response, err := (*client).Run(ctx, &proto.RunRequest{ Command: runOptions.Command, Args: runOptions.Args, AllocatePseudoTty: runOptions.AllocatePseudoTty, WorkingDir: runOptions.WorkingDir, Meta: meta, EnvVars: runOptions.Env, }) if err != nil { return nil, errors.New(err) } // Determine log levels based on headless mode (similar to buildOutWriter/buildErrWriter) stdoutLogLevel := log.StdoutLevel stderrLogLevel := log.StderrLevel stdoutWriter := writer.ExtractOriginalWriter(runOptions.Writers.Writer) stderrWriter := writer.ExtractOriginalWriter(runOptions.Writers.ErrWriter) if runOptions.Headless && !runOptions.ForwardTFStdout { stdoutLogLevel = log.InfoLevel stderrLogLevel = log.ErrorLevel stdoutWriter = writer.ExtractOriginalWriter(runOptions.Writers.ErrWriter) } var ( output = util.CmdOutput{} // Use the original output writers (before they were wrapped by logTFOutput) // and create new writers with the engine logger engineStdout = logwriter.New( logwriter.WithLogger(l.WithOptions(log.WithOutput(stdoutWriter))), logwriter.WithDefaultLevel(stdoutLogLevel), logwriter.WithMsgSeparator("\n"), ) engineStderr = logwriter.New( logwriter.WithLogger(l.WithOptions(log.WithOutput(stderrWriter))), logwriter.WithDefaultLevel(stderrLogLevel), logwriter.WithMsgSeparator("\n"), ) stdout = io.MultiWriter(engineStdout, &output.Stdout) stderr = io.MultiWriter(engineStderr, &output.Stderr) ) var ( stdoutLineBuf, stderrLineBuf bytes.Buffer resultCode int ) for { runResp, recvErr := response.Recv() if recvErr != nil || runResp == nil { break } responseType := runResp.GetResponse() if responseType == nil { continue } switch resp := responseType.(type) { case *proto.RunResponse_Stdout: if resp.Stdout != nil { if err = processStream(resp.Stdout.GetContent(), &stdoutLineBuf, stdout); err != nil { return nil, errors.New(err) } } case *proto.RunResponse_Stderr: if resp.Stderr != nil { if err = processStream(resp.Stderr.GetContent(), &stderrLineBuf, stderr); err != nil { return nil, errors.New(err) } } case *proto.RunResponse_ExitResult: if resp.ExitResult != nil { resultCode = int(resp.ExitResult.GetCode()) } case *proto.RunResponse_Log: if resp.Log != nil { if logContent := resp.Log.GetContent(); logContent != "" { logEngineMessage(l, resp.Log.GetLevel(), logContent) } } } } if err = flushBuffer(&stdoutLineBuf, stdout); err != nil { return nil, errors.New(err) } if err = flushBuffer(&stderrLineBuf, stderr); err != nil { return nil, errors.New(err) } l.Debugf("Engine execution done in %v", runOptions.WorkingDir) if resultCode != 0 { err = util.ProcessExecutionError{ Err: errors.Errorf("command failed with exit code %d", resultCode), Output: output, WorkingDir: runOptions.WorkingDir, RootWorkingDir: runOptions.RootWorkingDir, LogShowAbsPaths: runOptions.Writers.LogShowAbsPaths, Command: runOptions.Command, Args: runOptions.Args, DisableSummary: runOptions.Writers.LogDisableErrorSummary, } return nil, errors.New(err) } return &output, nil } // processStream handles the character buffering and line printing for a given stream func processStream(data string, lineBuf *bytes.Buffer, output io.Writer) error { for _, ch := range data { lineBuf.WriteRune(ch) if ch == '\n' { if _, err := fmt.Fprint(output, lineBuf.String()); err != nil { return errors.New(err) } lineBuf.Reset() } } return nil } // flushBuffer prints any remaining data in the buffer func flushBuffer(lineBuf *bytes.Buffer, output io.Writer) error { if lineBuf.Len() > 0 { if _, err := fmt.Fprint(output, lineBuf.String()); err != nil { return errors.New(err) } } return nil } var ErrEngineInitFailed = errors.New("engine init failed") // initialize engine for working directory func initialize(ctx context.Context, l log.Logger, runOptions *ExecutionOptions, client *proto.EngineClient) error { meta, err := ConvertMetaToProtobuf(runOptions.EngineConfig.Meta) if err != nil { return errors.New(err) } l.Debugf("Running init for engine in %s", runOptions.WorkingDir) request, err := (*client).Init(ctx, &proto.InitRequest{ EnvVars: runOptions.Env, WorkingDir: runOptions.WorkingDir, Meta: meta, }) if err != nil { return errors.New(err) } l.Debugf("Reading init output for engine in %s", runOptions.WorkingDir) return ReadEngineOutput(runOptions, true, func() (*OutputLine, error) { output, err := request.Recv() if err != nil { return nil, err } if output == nil { return nil, nil } outputLine := &OutputLine{} //nolint:dupl // Similar structure to shutdown response handling, but different protobuf types switch resp := output.GetResponse().(type) { case *proto.InitResponse_Stdout: if resp.Stdout != nil { outputLine.Stdout = resp.Stdout.GetContent() } case *proto.InitResponse_Stderr: if resp.Stderr != nil { outputLine.Stderr = resp.Stderr.GetContent() } case *proto.InitResponse_ExitResult: if resp.ExitResult != nil { exitCode := int(resp.ExitResult.GetCode()) if exitCode != 0 { l.Errorf("Engine init failed with exit code %d", exitCode) return nil, errors.Errorf("%w with exit code %d", ErrEngineInitFailed, exitCode) } } case *proto.InitResponse_Log: if resp.Log != nil { if logContent := resp.Log.GetContent(); logContent != "" { logEngineMessage(l, resp.Log.GetLevel(), logContent) } } } return outputLine, nil }) } var ErrEngineShutdownFailed = errors.New("engine shutdown failed") // shutdown engine for working directory func shutdown(ctx context.Context, l log.Logger, runOptions *ExecutionOptions, terragruntEngine *proto.EngineClient) error { meta, err := ConvertMetaToProtobuf(runOptions.EngineConfig.Meta) if err != nil { return errors.New(err) } request, err := (*terragruntEngine).Shutdown(ctx, &proto.ShutdownRequest{ WorkingDir: runOptions.WorkingDir, Meta: meta, EnvVars: runOptions.Env, }) if err != nil { return errors.New(err) } l.Debugf("Reading shutdown output for engine in %s", runOptions.WorkingDir) return ReadEngineOutput(runOptions, true, func() (*OutputLine, error) { output, err := request.Recv() if err != nil { return nil, err } if output == nil { return nil, nil } outputLine := &OutputLine{} responseType := output.GetResponse() if responseType == nil { return outputLine, nil } //nolint:dupl // Similar structure to init response handling, but different protobuf types switch resp := responseType.(type) { case *proto.ShutdownResponse_Stdout: if resp.Stdout != nil { outputLine.Stdout = resp.Stdout.GetContent() } case *proto.ShutdownResponse_Stderr: if resp.Stderr != nil { outputLine.Stderr = resp.Stderr.GetContent() } case *proto.ShutdownResponse_ExitResult: if resp.ExitResult != nil { exitCode := int(resp.ExitResult.GetCode()) if exitCode != 0 { l.Errorf("Engine shutdown failed with exit code %d", exitCode) return nil, errors.Errorf("%w with exit code %d", ErrEngineShutdownFailed, exitCode) } } case *proto.ShutdownResponse_Log: if resp.Log != nil { if logContent := resp.Log.GetContent(); logContent != "" { logEngineMessage(l, resp.Log.GetLevel(), logContent) } } } return outputLine, nil }) } // OutputLine represents the output from the engine type OutputLine struct { Stdout string Stderr string } type outputFn func() (*OutputLine, error) // ReadEngineOutput reads the output from the engine, since grpc plugins don't have common type, // use lambda function to read bytes from the stream func ReadEngineOutput(runOptions *ExecutionOptions, forceStdErr bool, output outputFn) error { cmdStdout := runOptions.Writers.Writer cmdStderr := runOptions.Writers.ErrWriter for { response, err := output() if err != nil && (errors.Is(err, ErrEngineInitFailed) || errors.Is(err, ErrEngineShutdownFailed)) { return err } if response == nil || err != nil { break } if response.Stdout != "" { if forceStdErr { // redirect stdout to stderr if _, err := cmdStderr.Write([]byte(response.Stdout)); err != nil { return errors.New(err) } } else { if _, err := cmdStdout.Write([]byte(response.Stdout)); err != nil { return errors.New(err) } } } if response.Stderr != "" { if _, err := cmdStderr.Write([]byte(response.Stderr)); err != nil { return errors.New(err) } } } // TODO: Why does this lint need to be ignored? return nil //nolint:nilerr } // ConvertMetaToProtobuf converts metadata map to protobuf map func ConvertMetaToProtobuf(meta map[string]any) (map[string]*anypb.Any, error) { protoMeta := make(map[string]*anypb.Any) if meta == nil { return protoMeta, nil } for key, value := range meta { jsonData, err := json.Marshal(value) if err != nil { return nil, fmt.Errorf("error marshaling value to JSON: %w", err) } jsonStructValue, err := structpb.NewValue(string(jsonData)) if err != nil { return nil, err } v, err := anypb.New(jsonStructValue) if err != nil { return nil, err } protoMeta[key] = v } return protoMeta, nil } // extract extracts a ZIP file into a specified destination directory. func extract(l log.Logger, zipFile, destDir string) error { r, err := zip.OpenReader(zipFile) if err != nil { return errors.New(err) } defer func() { if closeErr := r.Close(); closeErr != nil { l.Warnf("warning: failed to close zip reader: %v", closeErr) } }() if err = os.MkdirAll(destDir, dirPerm); err != nil { return errors.New(err) } // Extract each file in the archive for _, file := range r.File { fPath := filepath.Join(destDir, file.Name) // Check for ZipSlip vulnerability if !strings.HasPrefix(fPath, filepath.Clean(destDir)+string(os.PathSeparator)) { return errors.New(err) } if file.FileInfo().IsDir() { // Create directories if err := os.MkdirAll(fPath, file.Mode()); err != nil { return errors.New(err) } continue } if err := os.MkdirAll(filepath.Dir(fPath), dirPerm); err != nil { return errors.New(err) } outFile, err := os.OpenFile(fPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) if err != nil { return errors.New(err) } defer func() { if closeErr := outFile.Close(); closeErr != nil { l.Warnf("warning: failed to close zip reader: %v", closeErr) } }() rc, err := file.Open() if err != nil { return errors.New(err) } defer func() { if closeErr := rc.Close(); closeErr != nil { l.Warnf("warning: failed to close file reader: %v", closeErr) } }() // Write file content if _, err := io.Copy(outFile, rc); err != nil { return errors.New(err) } } return nil } // detectFileType determines the type of file based on its magic bytes. func detectFileType(l log.Logger, filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", errors.New(err) } defer func() { if closeErr := file.Close(); closeErr != nil { l.Warnf("warning: failed to close file : %v", filePath) } }() const headerSize = 4 // 4 bytes are enough for common formats header := make([]byte, headerSize) if _, err := file.Read(header); err != nil { return "", errors.New(err) } switch { case bytes.HasPrefix(header, []byte("PK\x03\x04")): return "zip", nil case bytes.HasPrefix(header, []byte("\x1F\x8B")): return "gzip", nil case bytes.HasPrefix(header, []byte("ustar")): return "tar", nil default: return "", nil } } ================================================ FILE: internal/engine/engine_test.go ================================================ package engine_test import ( "io" "testing" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConvertMetaToProtobuf(t *testing.T) { t.Parallel() meta := map[string]any{ "key1": "value1", "key2": 42, } protoMeta, err := engine.ConvertMetaToProtobuf(meta) require.NoError(t, err) assert.NotNil(t, protoMeta) assert.Len(t, protoMeta, 2) } func TestReadEngineOutput(t *testing.T) { t.Parallel() runOptions := &engine.ExecutionOptions{ Writers: writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}, } outputReturned := false outputFn := func() (*engine.OutputLine, error) { if outputReturned { return nil, nil } outputReturned = true return &engine.OutputLine{ Stdout: "stdout output", Stderr: "stderr output", }, nil } err := engine.ReadEngineOutput(runOptions, false, outputFn) assert.NoError(t, err) } ================================================ FILE: internal/engine/public_keys.go ================================================ package engine const PublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- mQGNBGaBbooBDADTCKKFW1uV5krG++w0u4QA7r2H6t39NfEKb8bbssM2oFIiTsEY 6WQbddDAbzA9KFyIA47yga1nB3tOgih+4QwZF/Wctw63sfeKQ/kdT/p3lSwI1Rbq BuWJ0pSrZCsS8ldxNuel2Imnr3rZtB+jAWrfJio10T3paCy8HGE470ehXYpqlcUJ rOUxR4PTcLnWY0PrNfMgljXyFMLvqe1sG0LuPIH3ZbGZOzmdVyo/ngeJ9fluP8DC XZKEXqzGe58m6iJmDBUuRRV+LPVo8NrrVfF7waQrlGjaE4GZvvsmApxXv/iM9DIg NpZWE3vTH/pBSsc0HapVWD/DzYQpXhwcKWdF2wtpRrYOLFiXmdTHBga3xPDpXrID vPghYlW19j60A9o2MRzbjnPHvNwHKv5XPBIhLcoyWnNCp6WASTbiBRwDx3miM1ZT euQPagG68aGabkWdEH1Pa33ZEF5oDH7j9C9ALJlUhrk5zgFSRN5GKcm00K209g2M dlnvgWBjoUwYU0MAEQEAAbQdR3J1bnR3b3JrIDxpbmZvQGdydW50d29yay5pbz6J Ac4EEwEKADgWIQQbc6gAIzjCuyjbMPSvWWjac5v8XAUCZoFuigIbAwULCQgHAgYV CgkICwIEFgIDAQIeAQIXgAAKCRCvWWjac5v8XAwWC/9IptEC3WhW7j8BdBjDVy5W jaGb75PlL8pkQFBrfNPxiLGxuLi6xuON6zSIGtKZe4XTjwnVniyYyiyfSojrKCRT YCctVVvgoBaylybk8ppCysyID9xs0YqrhdCZvJyH+yLAXTdmkddzj906hRkW+xmq 7XLA2emNxv6P4mHJr9pd4aa+aloZceRZ0OgUju+8E/ZTvW6A5YYExSFoNPBlG9nn WrT/D6aO9gqyMzN6w888p+jo+6s3JDQ6WEnf5s2Ha8g1k/Fg0Tk6YrbhcaYVQHEW WrSj9wVrXWa7RjRrZTREOMe9zLI3YHIsmBM0KNgHzvmyyhPsw1hR7MBJJfHKfpJ9 SitdkyCFWlI/UZITEAcADZkRpvaixUvzIXAsk30aWGonCaXsqrdJwmpdLRJkC8xd W6D6rxDhqdVyxDRi7jas6mtOk7Ao7wFMDuedX1TB8yqkhvx96FaoG22Qoy4cma7C Zz2jO2+ix/xztd/wq56jl0DjgKqpk06lECy/9+niyim5AY0EZoFuigEMAL7fKX6T e1K3K1e/WcaqGNFUGYWxlZZoGhihUAotWYeseleQB5RUmj9lwazI9zH3pteke+lV VwPqRD9djsQOv/B28Q6YpOd7sDbqxM6GSXED61sBAsJyDvmm0p5X7bbKJeRxhrhV FJjFf9F3t5gZb5Kff0vNYzCPmemT7UFaNUwDbE9wjRl5oKZfyDeUBBXB9H8aFE0J wLyFTnPKSpedJx7IlTbnCCzhTn0H7TKAVNYwRpSYN2GOChMiowkJrqD22G9HVZth g/sBJlmAFvLy8Ed8ktbZ426Xm44WRS6MFglZJJKZSEXOdSla8F4GT0Zxd6hsc6A8 bLHQw34mFVmGZ6Q81+z2L3MV+zA3Dug3kEgRpH6g++KCX3+gpgsEugxli176rO1M CtZMnyR1fWBI9W8CuNm4MysImBHdOO73IUIsT2wiv4RTTGaLhU0YIfIEyojAFgEm S9BKCgF4BTP9FTOxxMZmINFTzDqi/b51qPBxBs6DXa/E7muOePzclQIBowARAQAB iQG2BBgBCgAgFiEEG3OoACM4wrso2zD0r1lo2nOb/FwFAmaBbooCGwwACgkQr1lo 2nOb/FzonAwAw1jzHGUMAIPuLZAQNhrhj05ZbuC2A7TvWiQba9W1HPHFUZJgrxKW KNPaWb8oCQR8JDJlWqiZG6hWTAJ66suPrLF0KNnbiZ5Us4+o7Nv5q1i4lxpJRgoY FuCDZbQHXPn3jzSEDQPSA62+ZyRGxXfpqgVPpT8IPzAdCRAhMuUZb62h+WX2ey91 rnRFIXOlPTbrOMPLaMnGBjDnsWuCQmxBCXeevBh3u8q4Wa1xCZiqN8T7PSUusalu xita/w5ZA+Tzwxe8VqrgutCJdj5m5OxXX4v10xbbyPfhhnaahMduGL60MV/noxy6 9TFlXIhgDj8dxV8wt8Tv/GZSSUALaBuPs9U7Q+fGiPFpC48Q75y0uS0QWXm0taxs Rm6AMowi8dJEq1BIKvdEO2lDJ1uSw5Xcamj6Nu0JrM8tc1uCFNYOAEHw2bDHIcHK +uFqASiTa9QRj1SpSRkbPJB3yuPgUr1DgEoolSlMOnUiI46K3I9APD54nuShVbVq iE6bHk4c9kBU =TmYc -----END PGP PUBLIC KEY BLOCK-----` ================================================ FILE: internal/engine/types.go ================================================ package engine // EngineOptions groups CLI-supplied engine options. type EngineOptions struct { // CachePath is the path to the cache directory for engine files. CachePath string // LogLevel is the custom log level for engine. LogLevel string // SkipChecksumCheck skips checksum verification for engine packages. SkipChecksumCheck bool // NoEngine disables IaC engines even when the iac-engine experiment is enabled. NoEngine bool } // EngineConfig represents the configurations for a Terragrunt engine. type EngineConfig struct { Meta map[string]any Source string Version string Type string } ================================================ FILE: internal/engine/verification.go ================================================ package engine import ( "bytes" "crypto/sha256" "encoding/hex" "os" "path/filepath" "strings" "github.com/ProtonMail/go-crypto/openpgp" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) // verifyFile verifies the checksums file and the signature file of the passed file func verifyFile(checkedFile, checksumsFile, signatureFile string) error { checksums, err := os.ReadFile(checksumsFile) if err != nil { return errors.New(err) } checksumsSignature, err := os.ReadFile(signatureFile) if err != nil { return errors.New(err) } // validate first checksum file signature keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(PublicKey)) if err != nil { return errors.New(err) } _, err = openpgp.CheckDetachedSignature(keyring, bytes.NewReader(checksums), bytes.NewReader(checksumsSignature), nil) if err != nil { return errors.New(err) } // verify checksums // calculate checksum of package file packageChecksum, err := util.FileSHA256(checkedFile) if err != nil { return errors.New(err) } // match expected checksum expectedChecksum := util.MatchSha256Checksum(checksums, []byte(filepath.Base(checkedFile))) if expectedChecksum == nil { return errors.Errorf("checksum list has no entry for %s", checkedFile) } var expectedSHA256Sum [sha256.Size]byte if _, err := hex.Decode(expectedSHA256Sum[:], expectedChecksum); err != nil { return errors.New(err) } if !bytes.Equal(expectedSHA256Sum[:], packageChecksum) { return errors.Errorf("checksum list has unexpected SHA-256 hash %x (expected %x)", packageChecksum, expectedSHA256Sum) } return nil } ================================================ FILE: internal/errorconfig/types.go ================================================ // Package errorconfig defines types for structured error handling configuration. package errorconfig import ( "fmt" "maps" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // ErrorCleanPattern is used to clean error messages when looking for retry and ignore patterns. var ErrorCleanPattern = regexp.MustCompile(`[^a-zA-Z0-9./'"():=\- ]+`) // Config is the extracted errors handling configuration. type Config struct { Retry map[string]*RetryConfig Ignore map[string]*IgnoreConfig } // RetryConfig represents the configuration for retrying specific errors. type RetryConfig struct { Name string RetryableErrors []*Pattern MaxAttempts int SleepIntervalSec int } // IgnoreConfig represents the configuration for ignoring specific errors. type IgnoreConfig struct { Signals map[string]any Name string Message string IgnorableErrors []*Pattern } // Pattern represents a regex pattern for matching errors, with optional negation. type Pattern struct { Pattern *regexp.Regexp `clone:"shadowcopy"` Negative bool } // Action represents the action to take when an error occurs. type Action struct { IgnoreSignals map[string]any IgnoreBlockName string RetryBlockName string IgnoreMessage string RetryAttempts int RetrySleepSecs int ShouldIgnore bool ShouldRetry bool } // MaxAttemptsReachedError is returned when the maximum number of retry attempts is reached. type MaxAttemptsReachedError struct { Err error MaxRetries int } func (e *MaxAttemptsReachedError) Error() string { return fmt.Sprintf("max retry attempts (%d) reached for error: %v", e.MaxRetries, e.Err) } // AttemptErrorRecovery attempts to recover from an error by checking the ignore and retry rules. func (c *Config) AttemptErrorRecovery(l log.Logger, err error, currentAttempt int) (*Action, error) { if err == nil { return nil, nil } errStr := ExtractErrorMessage(err) action := &Action{} l.Debugf("Attempting error recovery for error: %s", errStr) // First check ignore rules for _, ignoreBlock := range c.Ignore { isIgnorable := MatchesAnyRegexpPattern(errStr, ignoreBlock.IgnorableErrors) if !isIgnorable { continue } action.IgnoreBlockName = ignoreBlock.Name action.ShouldIgnore = true action.IgnoreMessage = ignoreBlock.Message action.IgnoreSignals = make(map[string]any) // Convert cty.Value map to regular map maps.Copy(action.IgnoreSignals, ignoreBlock.Signals) return action, nil } // Then check retry rules for _, retryBlock := range c.Retry { isRetryable := MatchesAnyRegexpPattern(errStr, retryBlock.RetryableErrors) if !isRetryable { continue } if currentAttempt >= retryBlock.MaxAttempts { return nil, &MaxAttemptsReachedError{ MaxRetries: retryBlock.MaxAttempts, Err: err, } } action.RetryBlockName = retryBlock.Name action.ShouldRetry = true action.RetryAttempts = retryBlock.MaxAttempts action.RetrySleepSecs = retryBlock.SleepIntervalSec return action, nil } // We encountered no error while attempting error recovery, even though the underlying error // is still present. Recovery did not error, the original error will be handled externally. return nil, nil } // ExtractErrorMessage extracts and cleans the error message for pattern matching. func ExtractErrorMessage(err error) string { var errText string // For ProcessExecutionError, match only against stderr and the underlying error, // not the full command string with flags. var processErr util.ProcessExecutionError if errors.As(err, &processErr) { errText = processErr.Output.Stderr.String() + "\n" + processErr.Err.Error() } else { errText = err.Error() } multilineText := log.RemoveAllASCISeq(errText) errorText := ErrorCleanPattern.ReplaceAllString(multilineText, " ") return strings.Join(strings.Fields(errorText), " ") } // MatchesAnyRegexpPattern checks if the input string matches any of the provided compiled patterns. func MatchesAnyRegexpPattern(input string, patterns []*Pattern) bool { for _, pattern := range patterns { isNegative := pattern.Negative matched := pattern.Pattern.MatchString(input) if matched { return !isNegative } } return false } ================================================ FILE: internal/errorconfig/types_test.go ================================================ package errorconfig_test import ( "bytes" "errors" "regexp" "testing" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestExtractErrorMessage_ExcludesCommandFlags(t *testing.T) { t.Parallel() var stderr bytes.Buffer stderr.WriteString("flag provided but not defined: -abc") err := util.ProcessExecutionError{ Err: errors.New("exit status 1"), Command: "tofu", Args: []string{"plan", "-lock-timeout=120m", "-input=false"}, WorkingDir: "/some/path", Output: util.CmdOutput{Stderr: stderr}, } msg := errorconfig.ExtractErrorMessage(err) // The extracted message should only contain stderr and the underlying error, // not the command string with flags. assert.NotContains(t, msg, "-lock-timeout") assert.NotContains(t, msg, "tofu plan") // Should contain the actual error text from stderr and the exit error assert.Contains(t, msg, "flag provided but not defined") assert.Contains(t, msg, "exit status 1") } func TestExtractErrorMessage_DoesNotFalselyMatchTimeout(t *testing.T) { t.Parallel() // Simulate the exact scenario from issue #5088: // Command has -lock-timeout=120m flag, but the actual error is unrelated to timeout. var stderr bytes.Buffer stderr.WriteString("flag provided but not defined: -abc") err := util.ProcessExecutionError{ Err: errors.New("exit status 1"), Command: "tofu", Args: []string{"plan", "-lock-timeout=120m", "-input=false", "-fes"}, WorkingDir: "/some/path", Output: util.CmdOutput{Stderr: stderr}, } timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern}, } msg := errorconfig.ExtractErrorMessage(err) // The timeout pattern should NOT match because the extracted message only // contains stderr and exit error, not the command flags. matched := errorconfig.MatchesAnyRegexpPattern(msg, patterns) assert.False(t, matched, "timeout pattern should NOT match when 'timeout' only appears in command flags; cleaned message: %s", msg) } func TestExtractErrorMessage_StillMatchesRealTimeout(t *testing.T) { t.Parallel() // When stderr actually contains "timeout", the pattern should match. var stderr bytes.Buffer stderr.WriteString("Error: timeout waiting for resource to become available") err := util.ProcessExecutionError{ Err: errors.New("exit status 1"), Command: "tofu", Args: []string{"apply", "-auto-approve"}, WorkingDir: "/some/path", Output: util.CmdOutput{Stderr: stderr}, } timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern}, } msg := errorconfig.ExtractErrorMessage(err) matched := errorconfig.MatchesAnyRegexpPattern(msg, patterns) assert.True(t, matched, "timeout pattern should match when stderr actually contains 'timeout'; cleaned message: %s", msg) } func TestExtractErrorMessage_StillMatchesTimeoutInStderrWithFlags(t *testing.T) { t.Parallel() // Even when the command has -lock-timeout flags, if stderr also contains "timeout", // the pattern should match. var stderr bytes.Buffer stderr.WriteString("Error: timeout waiting for state lock") err := util.ProcessExecutionError{ Err: errors.New("exit status 1"), Command: "tofu", Args: []string{"plan", "-lock-timeout=120m", "-input=false"}, WorkingDir: "/some/path", Output: util.CmdOutput{Stderr: stderr}, } timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern}, } msg := errorconfig.ExtractErrorMessage(err) matched := errorconfig.MatchesAnyRegexpPattern(msg, patterns) assert.True(t, matched, "timeout pattern should match when stderr actually contains 'timeout'; cleaned message: %s", msg) } func TestExtractErrorMessage_NonProcessError(t *testing.T) { t.Parallel() // For non-ProcessExecutionError errors, the full error string is used. err := errors.New("some generic error with timeout in it") msg := errorconfig.ExtractErrorMessage(err) assert.Contains(t, msg, "timeout") timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern}, } matched := errorconfig.MatchesAnyRegexpPattern(msg, patterns) assert.True(t, matched, "timeout pattern should match for non-ProcessExecutionError; cleaned message: %s", msg) } func TestErrorCleanPattern_PreservesCharacters(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ { name: "preserves hyphens in flags", input: "-lock-timeout=120m", expected: "-lock-timeout=120m", }, { name: "preserves equals signs", input: "-input=false", expected: "-input=false", }, { name: "strips control chars", input: "error\x00here", expected: "error here", }, { name: "preserves alphanumeric and standard punctuation", input: `Failed to execute "tofu plan" in /some/path`, expected: `Failed to execute "tofu plan" in /some/path`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := errorconfig.ErrorCleanPattern.ReplaceAllString(tt.input, " ") require.Equal(t, tt.expected, result) }) } } func TestMatchesAnyRegexpPattern_NoMatch(t *testing.T) { t.Parallel() timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern}, } matched := errorconfig.MatchesAnyRegexpPattern("no match here", patterns) assert.False(t, matched) } func TestMatchesAnyRegexpPattern_NegativePattern(t *testing.T) { t.Parallel() timeoutPattern := regexp.MustCompile(`(?s).*timeout.*`) patterns := []*errorconfig.Pattern{ {Pattern: timeoutPattern, Negative: true}, } matched := errorconfig.MatchesAnyRegexpPattern("timeout occurred", patterns) assert.False(t, matched, "negative pattern should invert the match") } ================================================ FILE: internal/errors/errors.go ================================================ // Package errors contains helper functions for wrapping errors with stack traces, stack output, and panic recovery. package errors import ( "fmt" goerrors "github.com/go-errors/errors" ) const ( newSkip = 2 errorfSkip = 2 ) // New creates a new instance of Error. // If the given value does not contain a stack trace, it will be created. func New(val any) error { if val == nil { return nil } return newWithSkip(newSkip, val) } // Errorf creates a new error with the given format and values. // It can be used as a drop-in replacement for fmt.Errorf() to provide descriptive errors in return values. // If none of the given values contains a stack trace, it will be created. func Errorf(format string, vals ...any) error { return errorfWithSkip(errorfSkip, format, vals...) } func newWithSkip(skip int, val any) error { if err, ok := val.(error); ok && ContainsStackTrace(err) { return fmt.Errorf("%w", err) } return goerrors.Wrap(val, skip) } func errorfWithSkip(skip int, format string, vals ...any) error { err := fmt.Errorf(format, vals...) //nolint:err113 for _, val := range vals { if val, ok := val.(error); ok && val != nil && ContainsStackTrace(val) { return err } } return goerrors.Wrap(err, skip) } ================================================ FILE: internal/errors/export.go ================================================ package errors import "errors" // As finds the first error in err's tree that matches target, and if one is found, sets // target to that error value and returns true. Otherwise, it returns false. func As(err error, target any) bool { return errors.As(err, target) } // Is reports whether any error in err's tree matches target. func Is(err, target error) bool { return errors.Is(err, target) } // Join returns an error that wraps the given errors. func Join(errs ...error) error { return errors.Join(errs...) } // Unwrap returns the result of calling the Unwrap method on err, if err's // type contains an Unwrap method returning error. // Otherwise, Unwrap returns nil. func Unwrap(err error) error { return errors.Unwrap(err) } ================================================ FILE: internal/errors/multierror.go ================================================ package errors import ( "fmt" "strings" "github.com/hashicorp/go-multierror" ) // MultiError is an error type to track multiple errors. type MultiError struct { inner *multierror.Error } // WrappedErrors returns the error slice that this Error is wrapping. func (errs *MultiError) WrappedErrors() []error { if errs.inner == nil { return nil } return errs.inner.WrappedErrors() } func (errs *MultiError) Unwrap() []error { return errs.WrappedErrors() } // ErrorOrNil returns an error interface if this Error represents // a list of errors, or returns nil if the list of errors is empty. func (errs *MultiError) ErrorOrNil() error { if errs == nil || errs.inner == nil { return nil } if err := errs.inner.ErrorOrNil(); err != nil { return errs } return nil } // Append is a helper function that will append more errors // onto a Multierror in order to create a larger errs-error. func (errs *MultiError) Append(appendErrs ...error) *MultiError { if errs == nil { errs = &MultiError{inner: new(multierror.Error)} } if errs.inner == nil { errs.inner = new(multierror.Error) } return &MultiError{inner: multierror.Append(errs.inner, appendErrs...)} } // Len implements sort.Interface function for length. func (errs *MultiError) Len() int { if errs == nil { errs = &MultiError{inner: new(multierror.Error)} } if errs.inner == nil { errs.inner = new(multierror.Error) } return len(errs.inner.Errors) } // Swap implements sort.Interface function for swapping elements. func (errs *MultiError) Swap(i, j int) { errs.inner.Errors[i], errs.inner.Errors[j] = errs.inner.Errors[j], errs.inner.Errors[i] } // Less implements sort.Interface function for determining order. func (errs *MultiError) Less(i, j int) bool { return errs.inner.Errors[i].Error() < errs.inner.Errors[j].Error() } // Error implements the error interface. func (errs *MultiError) Error() string { unwrappedErrs := UnwrapMultiErrors(errs) strs := make([]string, len(unwrappedErrs)) for i := range unwrappedErrs { strs[i] = addIndent(unwrappedErrs[i].Error()) } errStr := strings.Join(strs, "\n\n") if len(strs) == 1 { return fmt.Sprintf("error occurred:\n\n%s\n", errStr) } return fmt.Sprintf("%d errors occurred:\n\n%s\n", len(strs), errStr) } func addIndent(str string) string { // for output on Windows OS str = strings.ReplaceAll(str, "\r\n", "\n") rawLines := strings.Split(str, "\n") var lines []string //nolint:prealloc for i, line := range rawLines { format := " %s" if i == 0 { format = "* %s" } line = fmt.Sprintf(format, line) lines = append(lines, line) } return strings.Join(lines, "\n") } ================================================ FILE: internal/errors/util.go ================================================ package errors import ( "context" "errors" "fmt" "strings" "slices" goerrors "github.com/go-errors/errors" ) // ErrorStack returns an stack trace if available. func ErrorStack(err error) string { var errStacks []string for _, err := range UnwrapMultiErrors(err) { for { if err, ok := err.(interface{ ErrorStack() string }); ok { errStacks = append(errStacks, err.ErrorStack()) } if err = errors.Unwrap(err); err == nil { break } } } return strings.Join(errStacks, "\n") } // ContainsStackTrace returns true if the given error contain the stack trace. // Useful to avoid creating a nested stack trace. func ContainsStackTrace(err error) bool { for _, err := range UnwrapMultiErrors(err) { for { if err, ok := err.(interface{ ErrorStack() string }); ok && err != nil { return true } if err = errors.Unwrap(err); err == nil { break } } } return false } // IsContextCanceled returns `true` if error has occurred by event `context.Canceled` which is not really an error. func IsContextCanceled(err error) bool { return errors.Is(err, context.Canceled) } // IsError returns true if actual is the same type of error as expected. This method unwraps the given error objects (if they // are wrapped in objects with a stacktrace) and then does a simple equality check on them. func IsError(actual error, expected error) bool { return goerrors.Is(actual, expected) } // Recover tries to recover from panics, and if it succeeds, calls the given onPanic function with an error that // explains the cause of the panic. This function should only be called from a defer statement. func Recover(onPanic func(cause error)) { if rec := recover(); rec != nil { err, isError := rec.(error) if !isError { err = fmt.Errorf("%v", rec) //nolint:err113 } onPanic(New(err)) } } // UnwrapMultiErrors unwraps all nested multierrors into error slice. func UnwrapMultiErrors(err error) []error { errs := []error{err} for index := 0; index < len(errs); index++ { err := errs[index] for { if err, ok := err.(interface{ Unwrap() []error }); ok { errs = slices.Delete(errs, index, index+1) index-- errs = append(errs, err.Unwrap()...) break } if err = errors.Unwrap(err); err == nil { break } } } return errs } // UnwrapErrors unwraps all nested multierrors, and errors that were wrapped with `fmt.Errorf("%w", err)`. func UnwrapErrors(err error) []error { var errs []error for _, err := range UnwrapMultiErrors(err) { for { errs = append(errs, err) if err = errors.Unwrap(err); err == nil { break } } } return errs } ================================================ FILE: internal/experiment/errors.go ================================================ package experiment import ( "strings" ) // InvalidExperimentNameError is an error that is returned when an invalid experiment name is requested. type InvalidExperimentNameError struct { allowedNames []string } func NewInvalidExperimentNameError(allowedNames []string) *InvalidExperimentNameError { return &InvalidExperimentNameError{ allowedNames: allowedNames, } } func (err InvalidExperimentNameError) Error() string { return "allowed experiment(s): " + strings.Join(err.allowedNames, ", ") } func (err InvalidExperimentNameError) Is(target error) bool { _, ok := target.(*InvalidExperimentNameError) return ok } ================================================ FILE: internal/experiment/experiment.go ================================================ // Package experiment provides utilities used by Terragrunt to support an "experiment" mode. // By default, experiment mode is disabled, but when enabled, experimental features can be enabled. // These features are not yet stable and may change in the future. // // Note that any behavior outlined here should be documented in /docs/_docs/04_reference/experiments.md // // That is how users will know what to expect when they enable experiment mode, and how to customize it. package experiment import ( "slices" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( // Symlinks is the experiment that allows symlinks to be used in Terragrunt configurations. Symlinks = "symlinks" // CLIRedesign is an experiment that allows users to use new commands related to the CLI redesign. CLIRedesign = "cli-redesign" // Stacks is the experiment that allows stacks to be used in Terragrunt. Stacks = "stacks" // CAS is the experiment that enables using the CAS package for git operations // in the catalog command, which provides better performance through content-addressable storage. CAS = "cas" // Report is the experiment that enables the new run report. Report = "report" // RunnerPool is the experiment that allows using a pool of runners for parallel execution. RunnerPool = "runner-pool" // AutoProviderCacheDir is the experiment that automatically enables central // provider caching by setting TF_PLUGIN_CACHE_DIR. // // Only works with OpenTofu version >= 1.10. AutoProviderCacheDir = "auto-provider-cache-dir" // FilterFlag is the experiment that enables usage of the filter flag for filtering components FilterFlag = "filter-flag" // IacEngine is the experiment that enables usage of Terragrunt IaC engines for running IaC operations. IacEngine = "iac-engine" // DependencyFetchOutputFromState is the experiment that enables fetching dependency outputs // directly from state files instead of using terraform/tofu output commands. DependencyFetchOutputFromState = "dependency-fetch-output-from-state" ) const ( // StatusOngoing is the status of an experiment that is ongoing. StatusOngoing byte = iota // StatusCompleted is the status of an experiment that is completed. StatusCompleted ) type Experiments []*Experiment // NewExperiments returns a new Experiments map with all experiments disabled. // // Bottom values for each experiment are the defaults, so only the names of experiments need to be set. func NewExperiments() Experiments { return Experiments{ { Name: Symlinks, }, { Name: CLIRedesign, Status: StatusCompleted, }, { Name: Stacks, Status: StatusCompleted, }, { Name: CAS, }, { Name: Report, Status: StatusCompleted, }, { Name: RunnerPool, Status: StatusCompleted, }, { Name: AutoProviderCacheDir, Status: StatusCompleted, }, { Name: FilterFlag, Status: StatusCompleted, }, { Name: IacEngine, }, { Name: DependencyFetchOutputFromState, }, } } // Names returns all experiment names. func (exps Experiments) Names() []string { names := make([]string, 0, len(exps)) for _, exp := range exps { names = append(names, exp.Name) } slices.Sort(names) return names } // FilterByStatus returns experiments filtered by the given `status`. func (exps Experiments) FilterByStatus(status byte) Experiments { var found Experiments for _, experiment := range exps { if experiment.Status == status { found = append(found, experiment) } } return found } // Find searches and returns the experiment by the given `name`. func (exps Experiments) Find(name string) *Experiment { for _, experiment := range exps { if experiment.Name == name { return experiment } } return nil } // ExperimentMode enables the experiment mode. func (exps Experiments) ExperimentMode() { for _, e := range exps { if e.Status == StatusOngoing { e.Enabled = true } } } // EnableExperiment validates that the specified experiment name is valid and enables this experiment. func (exps Experiments) EnableExperiment(name string) error { for _, e := range exps { if e.Name == name { e.Enabled = true return nil } } return NewInvalidExperimentNameError(exps.FilterByStatus(StatusOngoing).Names()) } // NotifyCompletedExperiments logs the experiment names that are Enabled and have completed Status. func (exps Experiments) NotifyCompletedExperiments(logger log.Logger) { var completed Experiments for _, experiment := range exps.FilterByStatus(StatusCompleted) { if experiment.Enabled { completed = append(completed, experiment) } } if len(completed) == 0 { return } logger.Warnf("%s", NewCompletedExperimentsWarning(completed.Names()).String()) } // Evaluate returns true if the experiment is found and enabled otherwise returns false. func (exps Experiments) Evaluate(name string) bool { if experiment := exps.FilterByStatus(StatusOngoing).Find(name); experiment != nil { return experiment.Evaluate() } return false } // Experiment represents an experiment that can be enabled. // When the experiment is enabled, Terragrunt will behave in a way that uses some experimental functionality. type Experiment struct { // Name is the name of the experiment. Name string // Enabled determines if the experiment is enabled. Enabled bool // Status is the status of the experiment. Status byte } func (exps Experiment) String() string { return exps.Name } // Evaluate returns true the experiment is enabled. // // If the experiment is completed, consider it permanently enabled. func (exps Experiment) Evaluate() bool { return exps.Enabled || exps.Status == StatusCompleted } ================================================ FILE: internal/experiment/experiment_test.go ================================================ package experiment_test import ( "bytes" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testOngoingA = "test-ongoing-a" testOngoingB = "test-ongoing-b" testCompletedA = "test-completed-a" ) func newTestLogger() (log.Logger, *bytes.Buffer) { formatter := format.NewFormatter(placeholders.Placeholders{placeholders.Message()}) output := new(bytes.Buffer) logger := log.New(log.WithOutput(output), log.WithLevel(log.InfoLevel), log.WithFormatter(formatter)) return logger, output } func TestValidateExperiments(t *testing.T) { t.Parallel() testCases := []struct { expectedError error name string expectedWarning string experiments experiment.Experiments experimentNames []string }{ { name: "no experiments", experiments: experiment.Experiments{ { Name: testOngoingA, Status: experiment.StatusCompleted, }, { Name: experiment.CLIRedesign, }, }, experimentNames: []string{}, expectedWarning: "", expectedError: nil, }, { name: "valid experiment", experiments: experiment.Experiments{ { Name: testOngoingA, }, { Name: testOngoingB, }, }, experimentNames: []string{testOngoingA}, expectedWarning: "", expectedError: nil, }, { name: "invalid experiment", experiments: experiment.Experiments{ { Name: testCompletedA, Status: experiment.StatusCompleted, }, { Name: testOngoingA, }, }, experimentNames: []string{"invalid"}, expectedWarning: "", expectedError: experiment.NewInvalidExperimentNameError([]string{testOngoingA}), }, { name: "completed experiment", experiments: experiment.Experiments{ { Name: testCompletedA, Status: experiment.StatusCompleted, }, }, experimentNames: []string{testCompletedA}, expectedWarning: "The following experiment(s) are already completed: " + testCompletedA + ". Please remove any completed experiments, as setting them no longer does anything. For a list of all ongoing experiments, and the outcomes of previous experiments, see https://docs.terragrunt.com/reference/experiments", expectedError: nil, }, { name: "invalid and completed experiment", experiments: experiment.Experiments{ { Name: testCompletedA, Status: experiment.StatusCompleted, }, { Name: testOngoingA, }, }, experimentNames: []string{"invalid", testCompletedA}, expectedWarning: "The following experiment(s) are already completed: " + testCompletedA + ". Please remove any completed experiments, as setting them no longer does anything. For a list of all ongoing experiments, and the outcomes of previous experiments, see https://docs.terragrunt.com/reference/experiments", expectedError: experiment.NewInvalidExperimentNameError([]string{testOngoingA}), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() for _, name := range tc.experimentNames { if err := tc.experiments.EnableExperiment(name); err != nil { require.EqualError(t, err, tc.expectedError.Error()) } else { require.NoError(t, err) } } logger, output := newTestLogger() tc.experiments.NotifyCompletedExperiments(logger) if tc.expectedWarning == "" { assert.Empty(t, output.String()) return } assert.Contains(t, strings.TrimSpace(output.String()), tc.expectedWarning) }) } } ================================================ FILE: internal/experiment/warnings.go ================================================ package experiment import ( "strings" ) // CompletedExperimentsWarning is a warning that is returned when completed experiments are requested. type CompletedExperimentsWarning struct { experimentsNames []string } func NewCompletedExperimentsWarning(experimentsNames []string) *CompletedExperimentsWarning { return &CompletedExperimentsWarning{ experimentsNames: experimentsNames, } } func (w CompletedExperimentsWarning) String() string { return "The following experiment(s) are already completed: " + strings.Join(w.experimentsNames, ", ") + ". Please remove any completed experiments, as setting them no longer does anything. For a list of all ongoing experiments, and the outcomes of previous experiments, see https://docs.terragrunt.com/reference/experiments" } ================================================ FILE: internal/filter/ast.go ================================================ package filter import ( "path/filepath" "strconv" "github.com/gobwas/glob" "github.com/gruntwork-io/terragrunt/internal/component" ) // Expression is the interface that all AST nodes must implement. type Expression interface { // expressionNode is a marker method to distinguish expression nodes. expressionNode() // String returns a string representation of the expression for debugging. String() string // RequiresDiscovery returns the first expression that requires discovery of Terragrunt components if any do. // Additionally, it returns a secondary value of true if any do. RequiresDiscovery() (Expression, bool) // RequiresParse returns the first expression that requires parsing Terragrunt HCL configurations if any do. // Additionally, it returns a secondary value of true if any do. RequiresParse() (Expression, bool) // IsRestrictedToStacks returns true if the expression is restricted to stacks. IsRestrictedToStacks() bool // Negated returns the equivalent expression with negation flipped. Negated() Expression } // Expressions is a slice of expressions. type Expressions []Expression // PathExpression represents a path or glob filter (e.g., "./path/**/*" or "/absolute/path"). type PathExpression struct { compiledGlob glob.Glob Value string } // NewPathFilter creates a new PathFilter with eager glob compilation. func NewPathFilter(value string) (*PathExpression, error) { pattern := filepath.Clean(filepath.ToSlash(value)) compiled, err := glob.Compile(pattern, '/') if err != nil { return nil, err } return &PathExpression{Value: value, compiledGlob: compiled}, nil } // Glob returns the pre-compiled glob pattern. func (p *PathExpression) Glob() glob.Glob { return p.compiledGlob } func (p *PathExpression) expressionNode() {} func (p *PathExpression) String() string { return p.Value } func (p *PathExpression) RequiresDiscovery() (Expression, bool) { return p, false } func (p *PathExpression) RequiresParse() (Expression, bool) { return p, false } func (p *PathExpression) IsRestrictedToStacks() bool { return false } func (p *PathExpression) Negated() Expression { return NewPrefixExpression("!", p) } // AttributeExpression represents a key-value attribute filter (e.g., "name=my-app"). type AttributeExpression struct { compiledGlob glob.Glob Key string Value string } // NewAttributeExpression creates a new AttributeExpression with eager glob compilation // for attributes that support glob matching (name, reading, source). func NewAttributeExpression(key string, value string) (*AttributeExpression, error) { expr := &AttributeExpression{Key: key, Value: value} if expr.supportsGlob() { pattern := value if key == AttributeReading { pattern = filepath.Clean(filepath.ToSlash(pattern)) } compiled, err := glob.Compile(pattern, '/') if err != nil { return nil, err } expr.compiledGlob = compiled } return expr, nil } // NewTypeExpression creates a new AttributeExpression for the "type" attribute. // Type filters do not support glob matching, so this constructor cannot fail. func NewTypeExpression(kind component.Kind) *AttributeExpression { return &AttributeExpression{Key: AttributeType, Value: string(kind)} } // Glob returns the pre-compiled glob pattern. func (a *AttributeExpression) Glob() glob.Glob { return a.compiledGlob } // supportsGlob returns true if the attribute filter supports glob patterns. func (a *AttributeExpression) supportsGlob() bool { return a.Key == AttributeReading || a.Key == AttributeName || a.Key == AttributeSource } func (a *AttributeExpression) expressionNode() {} func (a *AttributeExpression) String() string { return a.Key + "=" + a.Value } func (a *AttributeExpression) RequiresDiscovery() (Expression, bool) { return a, true } func (a *AttributeExpression) RequiresParse() (Expression, bool) { switch a.Key { // All of these attributes can be determined based on the component + configuration filepath. case AttributeName, AttributeType, AttributeExternal: return nil, false // We only know what a component reads if we parse it. case AttributeReading: return a, true // We default to true to be conservative in-case we forget to register // a new attribute here that does require parsing. default: return nil, true } } func (a *AttributeExpression) IsRestrictedToStacks() bool { return a.Key == "type" && a.Value == "stack" } func (a *AttributeExpression) Negated() Expression { return NewPrefixExpression("!", a) } // PrefixExpression represents a prefix operator expression (e.g., "!name=foo"). type PrefixExpression struct { Right Expression Operator string } // NewPrefixExpression creates a new PrefixExpression. func NewPrefixExpression(operator string, right Expression) *PrefixExpression { return &PrefixExpression{Operator: operator, Right: right} } func (p *PrefixExpression) expressionNode() {} func (p *PrefixExpression) String() string { return p.Operator + p.Right.String() } func (p *PrefixExpression) RequiresDiscovery() (Expression, bool) { return p.Right.RequiresDiscovery() } func (p *PrefixExpression) RequiresParse() (Expression, bool) { return p.Right.RequiresParse() } func (p *PrefixExpression) IsRestrictedToStacks() bool { switch p.Operator { case "!": switch a := p.Right.(type) { case *AttributeExpression: switch a.Key { case "type": return a.Value != "stack" default: return false } default: return false } default: return false } } func (p *PrefixExpression) Negated() Expression { switch p.Operator { case "!": return p.Right default: return NewPrefixExpression("!", p.Right) } } // InfixExpression represents an infix operator expression (e.g., "./apps/* | name=bar"). type InfixExpression struct { Left Expression Right Expression Operator string } // NewInfixExpression creates a new InfixExpression. func NewInfixExpression(left Expression, operator string, right Expression) *InfixExpression { return &InfixExpression{Left: left, Operator: operator, Right: right} } func (i *InfixExpression) expressionNode() {} func (i *InfixExpression) String() string { return i.Left.String() + " " + i.Operator + " " + i.Right.String() } func (i *InfixExpression) RequiresDiscovery() (Expression, bool) { if _, ok := i.Left.RequiresDiscovery(); ok { return i, true } if _, ok := i.Right.RequiresDiscovery(); ok { return i, true } return nil, false } func (i *InfixExpression) RequiresParse() (Expression, bool) { if _, ok := i.Left.RequiresParse(); ok { return i, true } if _, ok := i.Right.RequiresParse(); ok { return i, true } return nil, false } func (i *InfixExpression) IsRestrictedToStacks() bool { switch i.Operator { case "|": return i.Left.IsRestrictedToStacks() || i.Right.IsRestrictedToStacks() default: return false } } func (i *InfixExpression) Negated() Expression { switch i.Operator { case "|": return NewInfixExpression(i.Left.Negated(), i.Operator, i.Right) default: return NewInfixExpression(i.Left.Negated(), i.Operator, i.Right) } } // GraphExpression represents a graph traversal expression (e.g., "...foo", "foo...", "..1foo", "foo..2"). // Depth fields control how many levels of dependencies/dependents to traverse. type GraphExpression struct { Target Expression IncludeDependents bool IncludeDependencies bool ExcludeTarget bool DependentDepth int DependencyDepth int } // NewGraphExpression creates a new GraphExpression for the given target. // Use the builder methods WithDependents, WithDependencies, and WithExcludeTarget // to configure graph traversal behavior. func NewGraphExpression(target Expression) *GraphExpression { return &GraphExpression{ Target: target, } } // WithDependents includes dependents (reverse dependencies) in the graph traversal. func (g *GraphExpression) WithDependents() *GraphExpression { g.IncludeDependents = true return g } // WithDependencies includes dependencies in the graph traversal. func (g *GraphExpression) WithDependencies() *GraphExpression { g.IncludeDependencies = true return g } // WithExcludeTarget excludes the target itself from the graph traversal results. func (g *GraphExpression) WithExcludeTarget() *GraphExpression { g.ExcludeTarget = true return g } func (g *GraphExpression) expressionNode() {} func (g *GraphExpression) String() string { result := "" if g.IncludeDependents { if g.DependentDepth > 0 { result += strconv.Itoa(g.DependentDepth) } result += "..." } if g.ExcludeTarget { result += "^" } result += g.Target.String() if g.IncludeDependencies { result += "..." if g.DependencyDepth > 0 { result += strconv.Itoa(g.DependencyDepth) } } return result } func (g *GraphExpression) RequiresDiscovery() (Expression, bool) { // Graph expressions require dependency discovery to traverse the graph return g, true } func (g *GraphExpression) RequiresParse() (Expression, bool) { // Graph expressions require parsing to traverse the graph. return g, true } func (g *GraphExpression) IsRestrictedToStacks() bool { return false } func (g *GraphExpression) Negated() Expression { return NewPrefixExpression("!", g) } // GitExpression represents a Git-based filter expression (e.g., "[main...HEAD]" or "[main]"). // It filters components based on changes between Git references. type GitExpression struct { FromRef string ToRef string } func NewGitExpression(fromRef, toRef string) *GitExpression { return &GitExpression{FromRef: fromRef, ToRef: toRef} } func (g *GitExpression) expressionNode() {} func (g *GitExpression) String() string { return "[" + g.FromRef + "..." + g.ToRef + "]" } func (g *GitExpression) RequiresDiscovery() (Expression, bool) { // Git filters require discovery to check which components changed between references return g, true } func (g *GitExpression) RequiresParse() (Expression, bool) { // Git filters don't require parsing - they compare file paths, not HCL content return nil, false } func (g *GitExpression) IsRestrictedToStacks() bool { return false } func (g *GitExpression) Negated() Expression { return NewPrefixExpression("!", g) } // GitExpressions is a slice of Git expressions. type GitExpressions []*GitExpression // UniqueGitRefs returns all unique Git references in a slice of expressions. func (e GitExpressions) UniqueGitRefs() []string { refSet := make(map[string]struct{}, len(e)) for _, expr := range e { refs := collectGitReferences(expr) for _, ref := range refs { refSet[ref] = struct{}{} } } result := make([]string, 0, len(refSet)) for ref := range refSet { result = append(result, ref) } return result } ================================================ FILE: internal/filter/ast_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRestrictToStacks(t *testing.T) { t.Parallel() tests := []struct { exprFn func(t *testing.T) filter.Expression name string expected bool }{ { name: "path filter", exprFn: func(t *testing.T) filter.Expression { t.Helper() return mustPath(t, "./apps/*") }, expected: false, }, { name: "attribute filter restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return mustAttr(t, "type", "stack") }, expected: true, }, { name: "attribute filter not restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return mustAttr(t, "name", "foo") }, expected: false, }, { name: "prefix expression restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewPrefixExpression("!", mustAttr(t, "type", "unit")) }, expected: true, }, { name: "prefix expression not restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewPrefixExpression("!", mustAttr(t, "name", "foo")) }, expected: false, }, { name: "infix expression restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewInfixExpression(mustAttr(t, "type", "stack"), "|", mustAttr(t, "external", "true")) }, expected: true, }, { name: "infix expression also restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewInfixExpression(mustAttr(t, "external", "true"), "|", mustAttr(t, "type", "stack")) }, expected: true, }, { name: "infix expression not restricted to stacks", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewInfixExpression(mustAttr(t, "name", "foo"), "|", mustAttr(t, "external", "true")) }, expected: false, }, { name: "graph expression", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewGraphExpression(mustAttr(t, "name", "foo")).WithDependents() }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() expr := tt.exprFn(t) assert.Equal(t, tt.expected, expr.IsRestrictedToStacks()) }) } } func mustPath(t *testing.T, value string) *filter.PathExpression { t.Helper() expr, err := filter.NewPathFilter(value) require.NoError(t, err) return expr } func mustAttr(t *testing.T, key, value string) *filter.AttributeExpression { t.Helper() expr, err := filter.NewAttributeExpression(key, value) require.NoError(t, err) return expr } ================================================ FILE: internal/filter/candidacy.go ================================================ package filter // GraphDirection represents the direction of graph traversal. type GraphDirection int const ( // GraphDirectionNone indicates no graph traversal. GraphDirectionNone GraphDirection = iota // GraphDirectionDependencies indicates traversing dependencies (downstream). GraphDirectionDependencies // GraphDirectionDependents indicates traversing dependents (upstream). GraphDirectionDependents // GraphDirectionBoth indicates traversing both directions. GraphDirectionBoth ) // String returns a string representation of the GraphDirection. func (d GraphDirection) String() string { switch d { case GraphDirectionNone: return "none" case GraphDirectionDependencies: return "dependencies" case GraphDirectionDependents: return "dependents" case GraphDirectionBoth: return "both" default: return "unknown" } } // IsNegated returns true if the expression starts with a negation operator. func IsNegated(expr Expression) bool { switch node := expr.(type) { case *PrefixExpression: return node.Operator == "!" case *InfixExpression: return IsNegated(node.Left) default: return false } } ================================================ FILE: internal/filter/candidacy_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" ) func TestIsNegated(t *testing.T) { t.Parallel() tests := []struct { exprFn func(t *testing.T) filter.Expression name string expected bool }{ { name: "path expression", exprFn: func(t *testing.T) filter.Expression { t.Helper() return mustPath(t, "./foo") }, expected: false, }, { name: "negated path", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewPrefixExpression("!", mustPath(t, "./foo")) }, expected: true, }, { name: "double negation", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewPrefixExpression("!", filter.NewPrefixExpression("!", mustPath(t, "./foo"))) }, expected: true, }, { name: "infix with negated left", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewInfixExpression( filter.NewPrefixExpression("!", mustPath(t, "./foo")), "|", mustPath(t, "./bar"), ) }, expected: true, }, { name: "infix with non-negated left", exprFn: func(t *testing.T) filter.Expression { t.Helper() return filter.NewInfixExpression( mustPath(t, "./foo"), "|", filter.NewPrefixExpression("!", mustPath(t, "./bar")), ) }, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := filter.IsNegated(tt.exprFn(t)) assert.Equal(t, tt.expected, result) }) } } func TestGraphDirection_String(t *testing.T) { t.Parallel() tests := []struct { expected string direction filter.GraphDirection }{ {"none", filter.GraphDirectionNone}, {"dependencies", filter.GraphDirectionDependencies}, {"dependents", filter.GraphDirectionDependents}, {"both", filter.GraphDirectionBoth}, {"unknown", filter.GraphDirection(999)}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.direction.String()) }) } } ================================================ FILE: internal/filter/classifier.go ================================================ package filter import ( "slices" "github.com/gruntwork-io/terragrunt/internal/component" ) // ClassificationStatus indicates whether a component is definitely included, a candidate, or excluded. type ClassificationStatus int const ( // StatusDiscovered indicates the component is definitely included in results. StatusDiscovered ClassificationStatus = iota // StatusCandidate indicates the component might be included (needs further evaluation). StatusCandidate // StatusExcluded indicates the component is definitely excluded from results. StatusExcluded ) // String returns a string representation of the ClassificationStatus. func (cs ClassificationStatus) String() string { switch cs { case StatusDiscovered: return "discovered" case StatusCandidate: return "candidate" case StatusExcluded: return "excluded" default: return "unknown" } } // CandidacyReason explains why a component is classified as a candidate. type CandidacyReason int const ( // CandidacyReasonNone indicates no candidacy reason (component is discovered or excluded). CandidacyReasonNone CandidacyReason = iota // CandidacyReasonGraphTarget indicates the component matches a graph expression target // and needs the graph phase to determine if it should be included. CandidacyReasonGraphTarget // CandidacyReasonRequiresParse indicates the component needs parsing to evaluate // attribute filters (e.g., reading=config/*). CandidacyReasonRequiresParse // CandidacyReasonPotentialDependent indicates the component is a potential dependent // when dependent filters (e.g., ...vpc) exist. These components need to be parsed // to build the dependency graph and determine if they are dependents of the target. CandidacyReasonPotentialDependent ) // String returns a string representation of the CandidacyReason. func (cr CandidacyReason) String() string { switch cr { case CandidacyReasonNone: return "none" case CandidacyReasonGraphTarget: return "graph-target" case CandidacyReasonRequiresParse: return "requires-parse" case CandidacyReasonPotentialDependent: return "potential-dependent" default: return "unknown" } } // ClassificationContext provides context for component classification. type ClassificationContext struct { // ParseDataAvailable indicates whether parsed data is available for classification. ParseDataAvailable bool } // GraphExpressionInfo contains information about a graph expression for the classifier. type GraphExpressionInfo struct { // Target is the target expression within the graph expression. Target Expression // FullExpression is the complete graph expression. FullExpression *GraphExpression // Index is the position of this expression in the original filter list. Index int // IncludeDependencies indicates if dependencies should be traversed. IncludeDependencies bool // IncludeDependents indicates if dependents should be traversed. IncludeDependents bool // ExcludeTarget indicates if the target itself should be excluded from results (^ prefix). ExcludeTarget bool // IsNegated indicates if this graph expression is within a negation (e.g., !...db). IsNegated bool // DependencyDepth is the maximum depth for dependency traversal. DependencyDepth int // DependentDepth is the maximum depth for dependent traversal. DependentDepth int } // Classifier analyzes filter expressions to efficiently classify components // as discovered, candidate, or excluded without full evaluation. type Classifier struct { filesystemExprs []Expression parseExprs []Expression graphExprs []*GraphExpressionInfo gitExprs []*GitExpression negatedExprs []Expression hasPositiveFilters bool } // NewClassifier creates a new Classifier that categorizes all filter expressions // for efficient component classification. // It separates filters into filesystem-evaluable, parse-required, and graph expressions. func NewClassifier(filters Filters) *Classifier { c := &Classifier{} for i, f := range filters { expr := f.Expression() if expr == nil { continue } c.analyzeExpression(expr, i) } return c } // Classify determines whether a component should be discovered, is a candidate, // or should be excluded based on the analyzed filters. // // Returns the classification status, the reason for candidacy (if applicable), // and the index of the matching graph expression (-1 if not a graph target match). // // Classification algorithm: // 1. Check if component ONLY matches negated filters -> EXCLUDED // 2. Check if parse expressions exist and parse data unavailable -> CANDIDATE (RequiresParse) // 3. Check if component matches any positive filesystem filter -> DISCOVERED // 4. Check if component matches any git expression -> DISCOVERED // 5. Check if component matches any graph expression target -> CANDIDATE (GraphTarget, returns index) // 6. Check if dependent filters exist and parse data unavailable -> CANDIDATE (PotentialDependent) // 7. If negated expressions exist and component doesn't match any -> DISCOVERED (negation acts as inclusion) // 8. If positive filters exist but no match -> EXCLUDED (exclude-by-default) // 9. If no positive filters exist -> DISCOVERED (include-by-default) func (c *Classifier) Classify(comp component.Component, classCtx ClassificationContext) (ClassificationStatus, CandidacyReason, int) { hasNegativeMatch := c.matchesAnyNegated(comp) hasPositiveMatch := c.matchesAnyPositive(comp, classCtx) // Before excluding due to negation, check if the component matches a negated graph expression target. // If so, we need to process it through the graph phase to discover dependencies/dependents // that should also be excluded. The final filter evaluation will handle the actual exclusion. if hasNegativeMatch && !hasPositiveMatch { if graphIdx := c.matchesNegatedGraphExpressionTarget(comp); graphIdx >= 0 { return StatusCandidate, CandidacyReasonGraphTarget, graphIdx } return StatusExcluded, CandidacyReasonNone, -1 } matchesFilesystem := c.matchesFilesystemExpression(comp) matchesGit := c.matchesGitExpression(comp) if len(c.parseExprs) > 0 && !classCtx.ParseDataAvailable { return StatusCandidate, CandidacyReasonRequiresParse, -1 } if matchesFilesystem { return StatusDiscovered, CandidacyReasonNone, -1 } if matchesGit { return StatusDiscovered, CandidacyReasonNone, -1 } if graphIdx := c.matchesGraphExpressionTarget(comp); graphIdx >= 0 { return StatusCandidate, CandidacyReasonGraphTarget, graphIdx } if c.HasDependentFilters() && !classCtx.ParseDataAvailable { return StatusCandidate, CandidacyReasonPotentialDependent, -1 } if len(c.negatedExprs) > 0 && !hasNegativeMatch { return StatusDiscovered, CandidacyReasonNone, -1 } if c.hasPositiveFilters { return StatusExcluded, CandidacyReasonNone, -1 } return StatusDiscovered, CandidacyReasonNone, -1 } // analyzeExpression recursively analyzes an expression and categorizes it. func (c *Classifier) analyzeExpression(expr Expression, filterIndex int) { switch node := expr.(type) { case *PathExpression: c.filesystemExprs = append(c.filesystemExprs, node) c.hasPositiveFilters = true case *AttributeExpression: if _, requiresParse := node.RequiresParse(); requiresParse { c.parseExprs = append(c.parseExprs, node) } else { c.filesystemExprs = append(c.filesystemExprs, node) } c.hasPositiveFilters = true case *GraphExpression: info := &GraphExpressionInfo{ Target: node.Target, FullExpression: node, Index: filterIndex, IncludeDependencies: node.IncludeDependencies, IncludeDependents: node.IncludeDependents, ExcludeTarget: node.ExcludeTarget, DependencyDepth: node.DependencyDepth, DependentDepth: node.DependentDepth, } c.graphExprs = append(c.graphExprs, info) c.hasPositiveFilters = true case *GitExpression: c.gitExprs = append(c.gitExprs, node) c.hasPositiveFilters = true case *PrefixExpression: // Right now, the only prefix operator is "!". // If we encounter an unknown operator, just analyze the inner expression. if node.Operator != "!" { c.analyzeExpression(node.Right, filterIndex) break } c.negatedExprs = append(c.negatedExprs, node.Right) if _, requiresParse := node.Right.RequiresParse(); requiresParse { c.parseExprs = append(c.parseExprs, node.Right) } c.extractNegatedGraphExpressions(node.Right, filterIndex) case *InfixExpression: c.analyzeExpression(node.Left, filterIndex) c.analyzeExpression(node.Right, filterIndex) } } // extractNegatedGraphExpressions walks through a negated expression and extracts // any graph expressions found within it. This ensures that filters like "!...db" // or "!db..." trigger the graph discovery phase. func (c *Classifier) extractNegatedGraphExpressions(expr Expression, filterIndex int) { WalkExpressions(expr, func(e Expression) bool { if graphExpr, ok := e.(*GraphExpression); ok { info := &GraphExpressionInfo{ Target: graphExpr.Target, FullExpression: graphExpr, Index: filterIndex, IncludeDependencies: graphExpr.IncludeDependencies, IncludeDependents: graphExpr.IncludeDependents, ExcludeTarget: graphExpr.ExcludeTarget, DependencyDepth: graphExpr.DependencyDepth, DependentDepth: graphExpr.DependentDepth, IsNegated: true, } c.graphExprs = append(c.graphExprs, info) } return true }) } // matchesAnyNegated checks if the component matches any negated expression. func (c *Classifier) matchesAnyNegated(comp component.Component) bool { return slices.ContainsFunc(c.negatedExprs, func(expr Expression) bool { return MatchComponent(comp, expr) }) } // matchesAnyPositive checks if the component matches any positive (non-negated) expression. func (c *Classifier) matchesAnyPositive(comp component.Component, classCtx ClassificationContext) bool { if c.matchesFilesystemExpression(comp) { return true } if c.matchesGraphExpressionTarget(comp) >= 0 { return true } if c.matchesGitExpression(comp) { return true } if !classCtx.ParseDataAvailable || len(c.parseExprs) == 0 { return false } return slices.ContainsFunc(c.parseExprs, func(expr Expression) bool { return MatchComponent(comp, expr) }) } // matchesGitExpression checks if a component matches any git expression. // Components discovered from worktrees have a Ref set in their discovery context. func (c *Classifier) matchesGitExpression(comp component.Component) bool { discoveryCtx := comp.DiscoveryContext() if discoveryCtx == nil || discoveryCtx.Ref == "" { return false } return slices.ContainsFunc(c.gitExprs, func(gitExpr *GitExpression) bool { return discoveryCtx.Ref == gitExpr.FromRef || discoveryCtx.Ref == gitExpr.ToRef }) } // matchesFilesystemExpression checks if the component matches any filesystem-evaluable expression. func (c *Classifier) matchesFilesystemExpression(comp component.Component) bool { return slices.ContainsFunc(c.filesystemExprs, func(expr Expression) bool { return MatchComponent(comp, expr) }) } // matchesGraphExpressionTarget checks if the component matches any non-negated graph expression target. // Returns the index of the matching graph expression, or -1 if no match. // Negated graph expressions are handled separately by matchesNegatedGraphExpressionTarget. func (c *Classifier) matchesGraphExpressionTarget(comp component.Component) int { idx := slices.IndexFunc(c.graphExprs, func(info *GraphExpressionInfo) bool { if info.IsNegated { return false } return MatchComponent(comp, info.Target) }) if idx >= 0 { return c.graphExprs[idx].Index } return -1 } // matchesNegatedGraphExpressionTarget checks if the component matches any negated graph expression target. // Returns the index of the matching graph expression, or -1 if no match. // This is used to identify components that need graph traversal even when they would otherwise be excluded. func (c *Classifier) matchesNegatedGraphExpressionTarget(comp component.Component) int { idx := slices.IndexFunc(c.graphExprs, func(info *GraphExpressionInfo) bool { if !info.IsNegated { return false } return MatchComponent(comp, info.Target) }) if idx >= 0 { return c.graphExprs[idx].Index } return -1 } // GraphExpressions returns the analyzed graph expressions. func (c *Classifier) GraphExpressions() []*GraphExpressionInfo { return c.graphExprs } // HasPositiveFilters returns whether any positive (non-negated) filters exist. func (c *Classifier) HasPositiveFilters() bool { return c.hasPositiveFilters } // HasParseRequiredFilters returns whether any filters require HCL parsing. func (c *Classifier) HasParseRequiredFilters() bool { return len(c.parseExprs) > 0 } // HasGraphFilters returns whether any graph traversal filters exist. func (c *Classifier) HasGraphFilters() bool { return len(c.graphExprs) > 0 } // HasDependentFilters returns whether any graph expressions include dependent traversal. // This is used to determine if pre-graph dependency building is needed to populate // reverse links before dependent discovery can work. func (c *Classifier) HasDependentFilters() bool { return slices.ContainsFunc(c.graphExprs, func(expr *GraphExpressionInfo) bool { return expr.IncludeDependents }) } // ParseExpressions returns the expressions that require parsing. func (c *Classifier) ParseExpressions() []Expression { return c.parseExprs } // NegatedExpressions returns the negated expressions. func (c *Classifier) NegatedExpressions() []Expression { return c.negatedExprs } ================================================ FILE: internal/filter/classifier_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClassifier_NegatedGraphExpression_HasGraphFilters(t *testing.T) { t.Parallel() tests := []struct { name string filterStr string expectGraphFilter bool expectDependents bool }{ { name: "negated dependent filter triggers HasGraphFilters and HasDependentFilters", filterStr: "!...db", expectGraphFilter: true, expectDependents: true, }, { name: "negated dependency filter triggers HasGraphFilters", filterStr: "!db...", expectGraphFilter: true, expectDependents: false, }, { name: "non-negated dependent filter", filterStr: "...db", expectGraphFilter: true, expectDependents: true, }, { name: "non-negated dependency filter", filterStr: "db...", expectGraphFilter: true, expectDependents: false, }, { name: "simple path filter", filterStr: "./foo", expectGraphFilter: false, expectDependents: false, }, { name: "negated path filter", filterStr: "!./foo", expectGraphFilter: false, expectDependents: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() f, err := filter.Parse(tt.filterStr) require.NoError(t, err, "failed to parse filter") classifier := filter.NewClassifier(filter.Filters{f}) assert.Equal(t, tt.expectGraphFilter, classifier.HasGraphFilters(), "HasGraphFilters() mismatch for filter %q", tt.filterStr) assert.Equal(t, tt.expectDependents, classifier.HasDependentFilters(), "HasDependentFilters() mismatch for filter %q", tt.filterStr) }) } } func TestClassifier_NegatedGraphExpression_IsNegatedFlag(t *testing.T) { t.Parallel() tests := []struct { name string filterStr string expectIsNegated []bool }{ { name: "single negated dependent filter", filterStr: "!...db", expectIsNegated: []bool{true}, }, { name: "single negated dependency filter", filterStr: "!db...", expectIsNegated: []bool{true}, }, { name: "non-negated dependent filter", filterStr: "...db", expectIsNegated: []bool{false}, }, { name: "non-negated dependency filter", filterStr: "db...", expectIsNegated: []bool{false}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() f, err := filter.Parse(tt.filterStr) require.NoError(t, err, "failed to parse filter") classifier := filter.NewClassifier(filter.Filters{f}) graphExprs := classifier.GraphExpressions() require.Len(t, graphExprs, len(tt.expectIsNegated), "unexpected number of graph expressions") for i, expected := range tt.expectIsNegated { assert.Equal(t, expected, graphExprs[i].IsNegated, "IsNegated mismatch for graph expression %d", i) } }) } } func TestClassifier_MixedNegatedAndNonNegatedGraphFilters(t *testing.T) { t.Parallel() fooFilter, err := filter.Parse("...foo") require.NoError(t, err) barFilter, err := filter.Parse("!...bar") require.NoError(t, err) classifier := filter.NewClassifier(filter.Filters{fooFilter, barFilter}) assert.True(t, classifier.HasGraphFilters(), "should have graph filters") assert.True(t, classifier.HasDependentFilters(), "should have dependent filters") graphExprs := classifier.GraphExpressions() require.Len(t, graphExprs, 2, "should have 2 graph expressions") // First one is positive (...foo) assert.False(t, graphExprs[0].IsNegated, "first graph expression should not be negated") assert.Equal(t, 0, graphExprs[0].Index, "first graph expression should have index 0") assert.True(t, graphExprs[0].IncludeDependents, "first should include dependents") // Second one is negated (!...bar) assert.True(t, graphExprs[1].IsNegated, "second graph expression should be negated") assert.Equal(t, 1, graphExprs[1].Index, "second graph expression should have index 1") assert.True(t, graphExprs[1].IncludeDependents, "second should include dependents") } func TestClassifier_NestedNegatedGraphExpression(t *testing.T) { t.Parallel() target, err := filter.NewPathFilter("./db") require.NoError(t, err) graphExpr := filter.NewGraphExpression(target).WithDependencies() negatedExpr := filter.NewPrefixExpression("!", graphExpr) f := filter.NewFilter(negatedExpr, "!./db...") classifier := filter.NewClassifier(filter.Filters{f}) assert.True(t, classifier.HasGraphFilters(), "should have graph filters") assert.False(t, classifier.HasDependentFilters(), "should not have dependent filters (db... is dependencies)") graphExprs := classifier.GraphExpressions() require.Len(t, graphExprs, 1) assert.True(t, graphExprs[0].IsNegated) assert.False(t, graphExprs[0].IncludeDependents) assert.True(t, graphExprs[0].IncludeDependencies) } func TestClassifier_NegatedBidirectionalGraphExpression(t *testing.T) { t.Parallel() target, err := filter.NewPathFilter("./db") require.NoError(t, err) graphExpr := filter.NewGraphExpression(target).WithDependents().WithDependencies() negatedExpr := filter.NewPrefixExpression("!", graphExpr) f := filter.NewFilter(negatedExpr, "!...db...") classifier := filter.NewClassifier(filter.Filters{f}) assert.True(t, classifier.HasGraphFilters(), "should have graph filters") assert.True(t, classifier.HasDependentFilters(), "should have dependent filters") graphExprs := classifier.GraphExpressions() require.Len(t, graphExprs, 1) assert.True(t, graphExprs[0].IsNegated) assert.True(t, graphExprs[0].IncludeDependencies) assert.True(t, graphExprs[0].IncludeDependents) } func TestClassifier_Classify(t *testing.T) { t.Parallel() tests := []struct { name string componentPath string componentRef string filterStrs []string expectedStatus filter.ClassificationStatus expectedReason filter.CandidacyReason expectedIdx int parseDataAvailable bool expectIdxGteZero bool }{ { name: "no_filters_returns_discovered", filterStrs: nil, componentPath: "./apps/app1", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "only_matches_negation_returns_excluded", filterStrs: []string{"!./apps/app1"}, componentPath: "./apps/app1", expectedStatus: filter.StatusExcluded, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "matches_negated_graph_expression_target_returns_candidate", filterStrs: []string{"!...db"}, componentPath: "./libs/db", expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonGraphTarget, expectIdxGteZero: true, }, { name: "parse_expressions_without_parse_data_returns_candidate", filterStrs: []string{"reading=config/*.hcl"}, componentPath: "./apps/app1", parseDataAvailable: false, expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonRequiresParse, expectedIdx: -1, }, { name: "matches_filesystem_expression_returns_discovered", filterStrs: []string{"./apps/*"}, componentPath: "./apps/app1", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "matches_git_expression_returns_discovered", filterStrs: []string{"[main...feature]"}, componentPath: "./apps/app1", componentRef: "main", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "matches_graph_expression_target_returns_candidate", filterStrs: []string{"./libs/db..."}, componentPath: "./libs/db", expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonGraphTarget, expectedIdx: 0, }, { name: "dependent_filters_without_parse_data_returns_candidate", filterStrs: []string{"...vpc"}, componentPath: "./apps/app1", parseDataAvailable: false, expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonPotentialDependent, expectedIdx: -1, }, { name: "negation_exists_component_not_matching_returns_discovered", filterStrs: []string{"!./libs/db"}, componentPath: "./apps/app1", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "positive_filters_no_match_returns_excluded", filterStrs: []string{"./libs/*"}, componentPath: "./apps/app1", expectedStatus: filter.StatusExcluded, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "positive_match_with_negation_returns_discovered", filterStrs: []string{"!./apps/app1", "./apps/*"}, componentPath: "./apps/app1", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "multiple_graph_expressions_returns_correct_index", filterStrs: []string{"./libs/api...", "./libs/db..."}, componentPath: "./libs/db", expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonGraphTarget, expectedIdx: 1, }, { name: "graph_expression_index_with_preceding_non_graph_filter", filterStrs: []string{"./libs/api", "./libs/db..."}, componentPath: "./libs/db", expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonGraphTarget, expectedIdx: 1, }, { name: "graph_expression_index_with_multiple_preceding_non_graph_filters", filterStrs: []string{"./libs/api", "!./libs/cache", "./libs/db..."}, componentPath: "./libs/db", expectedStatus: filter.StatusCandidate, expectedReason: filter.CandidacyReasonGraphTarget, expectedIdx: 2, }, { name: "parse_expressions_with_parse_data_evaluates_normally", filterStrs: []string{"reading=config/*.hcl"}, componentPath: "./apps/app1", parseDataAvailable: true, expectedStatus: filter.StatusExcluded, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "dependent_filters_with_parse_data_no_potential_dependent", filterStrs: []string{"...vpc"}, componentPath: "./apps/app1", parseDataAvailable: true, expectedStatus: filter.StatusExcluded, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "git_expression_component_without_ref_no_match", filterStrs: []string{"[main...feature]"}, componentPath: "./apps/app1", expectedStatus: filter.StatusExcluded, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, { name: "name_attribute_filter_matches", filterStrs: []string{"name=app1"}, componentPath: "./apps/app1", expectedStatus: filter.StatusDiscovered, expectedReason: filter.CandidacyReasonNone, expectedIdx: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var filters filter.Filters for _, filterStr := range tt.filterStrs { f, err := filter.Parse(filterStr) require.NoError(t, err) filters = append(filters, f) } classifier := filter.NewClassifier(filters) var comp component.Component if tt.componentRef != "" { comp = newTestComponentWithRef(tt.componentPath, tt.componentRef) } else { comp = newTestComponent(tt.componentPath) } status, reason, idx := classifier.Classify(comp, filter.ClassificationContext{ ParseDataAvailable: tt.parseDataAvailable, }) assert.Equal(t, tt.expectedStatus, status) assert.Equal(t, tt.expectedReason, reason) if tt.expectIdxGteZero { assert.GreaterOrEqual(t, idx, 0) } else { assert.Equal(t, tt.expectedIdx, idx) } }) } } func TestClassifier_Classify_StatusString(t *testing.T) { t.Parallel() tests := []struct { expected string status filter.ClassificationStatus }{ {status: filter.StatusDiscovered, expected: "discovered"}, {status: filter.StatusCandidate, expected: "candidate"}, {status: filter.StatusExcluded, expected: "excluded"}, {status: filter.ClassificationStatus(99), expected: "unknown"}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.status.String()) }) } } func TestClassifier_Classify_CandidacyReasonString(t *testing.T) { t.Parallel() tests := []struct { expected string reason filter.CandidacyReason }{ {reason: filter.CandidacyReasonNone, expected: "none"}, {reason: filter.CandidacyReasonGraphTarget, expected: "graph-target"}, {reason: filter.CandidacyReasonRequiresParse, expected: "requires-parse"}, {reason: filter.CandidacyReasonPotentialDependent, expected: "potential-dependent"}, {reason: filter.CandidacyReason(99), expected: "unknown"}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.reason.String()) }) } } func newTestComponent(path string) component.Component { return component.NewUnit(path).WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } func newTestComponentWithRef(path, ref string) component.Component { return component.NewUnit(path).WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", Ref: ref, }) } ================================================ FILE: internal/filter/complex_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParser_ComplexDepthExpressions(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string expectError bool }{ { name: "depth with intersection", input: "1...foo | bar", expected: "1...name=foo | name=bar", }, { name: "both sides have depth", input: "foo...1 | bar...2", expected: "name=foo...1 | name=bar...2", }, { name: "full depth both sides of intersection", input: "1...foo...1 | 2...bar...2", expected: "1...name=foo...1 | 2...name=bar...2", }, { name: "negation with depth prefix", input: "!1...foo", expected: "!1...name=foo", }, { name: "negation with depth postfix", input: "!foo...1", expected: "!name=foo...1", }, { name: "intersection with negation and depth", input: "1...foo | !bar...2", expected: "1...name=foo | !name=bar...2", }, { name: "unlimited mixed with depth", input: "...foo | bar...1", expected: "...name=foo | name=bar...1", }, { name: "chained intersections with depth", input: "1...a | b...2 | 3...c", expected: "1...name=a | name=b...2 | 3...name=c", }, { name: "depth with path filter", input: "1..../apps/*", expected: "1..../apps/*", }, { name: "depth with braced path", input: "1...{my app}...2", expected: "1...my app...2", }, { name: "depth with attribute filter", input: "1...type=unit...2", expected: "1...type=unit...2", }, { name: "depth with caret and intersection", input: "1...^foo | bar...", expected: "1...^name=foo | name=bar...", }, { name: "parentheses treated as part of identifier", input: "1...(foo | bar)", expected: "1...name=(foo | name=bar)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() if tt.expectError { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.expected, expr.String()) }) } } ================================================ FILE: internal/filter/diagnostic.go ================================================ package filter import ( "fmt" "strings" ) // ANSI escape codes for colored output. const ( ansiReset = "\033[0m" ansiBold = "\033[1m" ansiRed = "\033[31m" ansiBlue = "\033[34m" ansiCyan = "\033[36m" ) // FormatDiagnostic produces an error message from a ParseError. // // These diagnostics are formatted like so: // // ``` // Filter parsing error: Missing Git reference // // --> --filter '[main...]' // // [main...] // ^ Expected second Git reference after '...' // // hint: Git filters with '...' require a reference on each side. e.g. '[main...HEAD]' // // ``` func FormatDiagnostic(err *ParseError, filterIndex int, useColor bool) string { var sb strings.Builder fmt.Fprintf(&sb, "Filter parsing error: %s\n", err.Title) var arrow string if useColor { arrow = fmt.Sprintf("%s%s --> %s", ansiBold, ansiBlue, ansiReset) } else { arrow = " --> " } if filterIndex > 0 { fmt.Fprintf(&sb, "%s--filter[%d] '%s'\n", arrow, filterIndex, err.Query) } else { fmt.Fprintf(&sb, "%s--filter '%s'\n", arrow, err.Query) } sb.WriteString("\n") fmt.Fprintf(&sb, " %s\n", err.Query) indent := " " spaces := strings.Repeat(" ", err.ErrorPosition) caret := "^" detail := " " + err.Message if useColor { fmt.Fprintf(&sb, "%s%s%s%s%s%s%s\n", indent, spaces, ansiBold, ansiRed, caret, ansiReset, detail) } else { fmt.Fprintf(&sb, "%s%s%s%s\n", indent, spaces, caret, detail) } hint := GetHint(err.ErrorCode, err.TokenLiteral, err.Query, err.Position) if hint != "" { sb.WriteString("\n") if useColor { fmt.Fprintf(&sb, " %s%shint:%s %s\n", ansiBold, ansiCyan, ansiReset, hint) } else { fmt.Fprintf(&sb, " hint: %s\n", hint) } } return sb.String() } ================================================ FILE: internal/filter/diagnostic_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testLoggerForDiagnostics creates a logger for tests with colors disabled. func testLoggerForDiagnostics() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)) } func TestFormatDiagnostic_UnexpectedToken(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Unexpected token", Message: "unexpected '^' after expression", Position: 4, ErrorPosition: 4, Query: "HEAD^", TokenLiteral: "^", TokenLength: 1, ErrorCode: filter.ErrorCodeUnexpectedToken, } result := filter.FormatDiagnostic(err, 0, false) assert.Contains(t, result, "Filter parsing error: Unexpected token") assert.Contains(t, result, " --> --filter 'HEAD^'") assert.Contains(t, result, " HEAD^") assert.Contains(t, result, "^ unexpected '^' after expression") assert.Contains(t, result, "hint:") assert.Contains(t, result, "Git") } func TestFormatDiagnostic_WithFilterIndex(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Unexpected token", Message: "unexpected '|'", Position: 0, ErrorPosition: 0, Query: "| foo", TokenLiteral: "|", TokenLength: 1, ErrorCode: filter.ErrorCodeUnexpectedToken, } result := filter.FormatDiagnostic(err, 2, false) assert.Contains(t, result, " --> --filter[2] '| foo'") } func TestFormatDiagnostic_MissingClosingBracket(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Unclosed Git filter expression", Message: "this Git-based expression is missing a closing ']'", Position: 12, ErrorPosition: 0, Query: "[main...HEAD", TokenLiteral: "", TokenLength: 1, ErrorCode: filter.ErrorCodeMissingClosingBracket, } result := filter.FormatDiagnostic(err, 0, false) assert.Contains(t, result, "Filter parsing error: Unclosed Git filter expression") assert.Contains(t, result, " ^ this Git-based expression is missing a closing ']'") assert.Contains(t, result, "hint: Git-based expressions require surrounding references with '[]'") } func TestFormatDiagnostic_EmptyGitFilter(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Empty Git filter", Message: "Git filter expression cannot be empty", Position: 1, ErrorPosition: 1, Query: "[]", TokenLiteral: "]", TokenLength: 1, ErrorCode: filter.ErrorCodeEmptyGitFilter, } result := filter.FormatDiagnostic(err, 0, false) assert.Contains(t, result, "Filter parsing error: Empty Git filter") assert.NotContains(t, result, "hint:") } func TestFormatDiagnostic_WithColor(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Unexpected token", Message: "unexpected '^' after expression", Position: 4, ErrorPosition: 4, Query: "HEAD^", TokenLiteral: "^", TokenLength: 1, ErrorCode: filter.ErrorCodeUnexpectedToken, } result := filter.FormatDiagnostic(err, 0, true) assert.Contains(t, result, "\033[") } func TestFormatDiagnostic_NoColor(t *testing.T) { t.Parallel() err := &filter.ParseError{ Title: "Unexpected token", Message: "unexpected '^' after expression", Position: 4, ErrorPosition: 4, Query: "HEAD^", TokenLiteral: "^", TokenLength: 1, ErrorCode: filter.ErrorCodeUnexpectedToken, } result := filter.FormatDiagnostic(err, 0, false) assert.NotContains(t, result, "\033[") } func TestGetHint_CaretAfterIdentifier(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeUnexpectedToken, "^", "HEAD^", 4) require.NotEmpty(t, hint) assert.Contains(t, hint, "Git") assert.Contains(t, hint, "[HEAD^]") } func TestGetHint_CaretAtStart(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeUnexpectedToken, "^", "^foo", 0) require.NotEmpty(t, hint) assert.Contains(t, hint, "excludes the target") } func TestGetHint_MissingClosingBracket(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeMissingClosingBracket, "", "[main...HEAD", 12) require.NotEmpty(t, hint) assert.Contains(t, hint, "[]") } func TestGetHint_MissingClosingBrace(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeMissingClosingBrace, "", "{my path", 8) require.NotEmpty(t, hint) assert.Contains(t, hint, "{}") } func TestGetHint_EmptyGitFilter(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeEmptyGitFilter, "]", "[]", 1) assert.Empty(t, hint) } func TestGetHint_PipeOperator(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeUnexpectedToken, "|", "| foo", 0) // Pipe errors have specific messages that are self-explanatory, no hint needed assert.Empty(t, hint) } func TestParseFilterQueries_RichDiagnostics(t *testing.T) { t.Parallel() _, err := filter.ParseFilterQueries(testLoggerForDiagnostics(), []string{"HEAD^"}) require.Error(t, err) errMsg := err.Error() // Check error structure assert.Contains(t, errMsg, "error:") assert.Contains(t, errMsg, " --> ") assert.Contains(t, errMsg, "HEAD^") assert.Contains(t, errMsg, "^") assert.Contains(t, errMsg, "hint:") } func TestParseFilterQueries_MultipleErrors(t *testing.T) { t.Parallel() _, err := filter.ParseFilterQueries(testLoggerForDiagnostics(), []string{"HEAD^", "[unclosed"}) require.Error(t, err) errMsg := err.Error() // Check both errors are present // First filter (index 0) shows as "--filter 'HEAD^'" without index assert.Contains(t, errMsg, "--filter 'HEAD^'") // Second filter (index 1) shows as "--filter[1]" assert.Contains(t, errMsg, "--filter[1]") assert.Contains(t, errMsg, "unclosed") } func TestParseFilterQueries_ValidFilters(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLoggerForDiagnostics(), []string{"name=foo", "./apps/*"}) require.NoError(t, err) assert.Len(t, filters, 2) } func TestParseFilterQueries_EmptyInput(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLoggerForDiagnostics(), []string{}) require.NoError(t, err) assert.Empty(t, filters) } ================================================ FILE: internal/filter/doc.go ================================================ // Package filter provides a parser and evaluator for filter query strings used to select Terragrunt components. // // # Overview // // The filter package implements a three-stage compiler architecture: // 1. Lexer: Tokenizes the input filter query string // 2. Parser: Builds an Abstract Syntax Tree (AST) from tokens // 3. Evaluator: Applies the filter logic to discovered Terragrunt components // // This design follows the classic compiler pattern and provides a clean separation of concerns // between syntax analysis and semantic evaluation. // // # Filter Syntax // // The filter package supports the following syntax elements: // // ## Path Filters // // Path filters match components by their file system path. They support glob patterns: // // ./apps/frontend # Exact path match // ./apps/* # Single-level wildcard // ./apps/**/api # Recursive wildcard // /absolute/path # Absolute path // // ## Attribute Filters // // Attribute filters match components by their attributes: // // name=my-app # Match by config name (directory basename) // type=unit # Match components of type "unit" // type=stack # Match components of type "stack" // external=true # Match external dependencies // external=false # Match internal dependencies (not external) // foo # Shorthand for name=foo // // ## Negation Operator (!) // // The negation operator excludes matching components: // // !name=legacy # Exclude components named "legacy" // !./apps/old # Exclude components at path ./apps/old // !foo # Exclude components named "foo" // !external=true # Exclude external dependencies // // ## Intersection Operator (|) // // The intersection operator refines/narrows results by applying filters from left to right. // Each filter in the chain further restricts the results from the previous filter. // The pipe character (|) is the only delimiter between filter expressions. // Whitespace is optional around operators but is NOT a delimiter itself. // // ./apps/* | name=web # Components in ./apps/* AND named "web" // ./apps/*|name=web # Same as above (spaces optional) // ./foo* | !./foobar* # Components in ./foo* AND NOT in ./foobar* // type=unit | !external=true # Internal components only // // Spaces within component names and paths are preserved: // // my app # Component named "my app" (with space) // ./my path/file # Path with spaces // // ## Braced Path Syntax ({}) // // Use braces to explicitly mark a path expression. This is useful when: // - The path doesn't start with ./ or / // - You want to be explicit that something is a path, not an identifier // // {./apps/*} # Explicitly a path // {my path/file} # Path without ./ prefix // {apps} # Treat "apps" as a path, not a name filter // // ## Graph Traversal Operators (...) // // Graph traversal operators include dependencies and/or dependents in the result: // // foo... # foo and all its dependencies (transitive) // ...foo # foo and all its dependents (transitive) // ...foo... # foo, all its dependencies, and all its dependents // ^foo... # All dependencies of foo, excluding foo itself // ...^foo # All dependents of foo, excluding foo itself // // Depth-limited traversal allows specifying how many levels to traverse. // Place the depth number on the outside of the ellipsis: // // foo...1 # foo and its direct dependencies only // foo...2 # foo and dependencies up to 2 levels deep // 1...foo # foo and its direct dependents only // 2...foo # foo and dependents up to 2 levels deep // 1...foo...2 # Direct dependents and dependencies up to 2 levels // // When depth is not specified, traversal is unlimited (default behavior). // // ## Numeric Directory Disambiguation // // When a purely numeric token appears adjacent to "...", it is interpreted as a depth: // // 1...1 # Parsed as: dependent depth 1, target "1" // # (first 1 is depth, second 1 is target) // // To explicitly specify a numeric directory as the target, use escape hatches: // // Braced path syntax (for path filters): // // {1}...1 # target path "1", dependency depth 1 // 1...{1} # dependent depth 1, target path "1" // 1...{1}...1 # depth 1 dependents, path "1", depth 1 dependencies // // Explicit name attribute (for name filters): // // name=1...1 # target name=1, dependency depth 1 // 1...name=1 # dependent depth 1, target name=1 // // Alphanumeric names are not ambiguous (only purely numeric tokens are depths): // // 1...1foo # dependent depth 1, target "1foo" // foo1...1 # target "foo1", dependency depth 1 // // # Operator Precedence // // Operators are evaluated with the following precedence (highest to lowest): // 1. Prefix operators (!) // 2. Infix operators (| - intersection/refinement, left-to-right) // // This means !foo | bar is evaluated as (!foo) | bar, not !(foo | bar). // The intersection operator applies filters left-to-right, each filter // refining/narrowing the results from the previous filter. // // # Usage Examples // // ## Basic Usage // // // Parse a filter query // filter, err := filter.Parse("./apps/* | !legacy", ".") // if err != nil { // log.Fatal(err) // } // // // Apply the filter to discovered components // // (typically obtained from discovery.Discover()) // components := []*component.Component{ // {Path: "./apps/app1", Kind: component.Unit}, // {Path: "./apps/legacy", Kind: component.Unit}, // {Path: "./libs/db", Kind: component.Unit}, // } // result, err := filter.Evaluate(components) // if err != nil { // log.Fatal(err) // } // // ## Multiple Filters (Union) // // Multiple filter queries can be combined using the Filters type, which applies // union (OR) semantics. This is different from using | within a single filter, // which applies intersection (AND) semantics. // // // Parse multiple filter queries // filters, err := filter.ParseFilterQueries([]string{ // "./apps/*", // Select all apps // "name=db", // OR select db // }, ".") // if err != nil { // log.Fatal(err) // } // // result, err := filters.Evaluate(components) // // Returns: all components in ./apps/* OR components named "db" // // Multiple filters are evaluated in two phases: // 1. Positive filters (non-negated) are evaluated and their results are unioned // 2. Negative filters (starting with !) are applied to remove matching components // // The ExcludeByDefault() method signals whether filters operate in exclude-by-default // mode. This is true if ANY filter doesn't start with a negation expression: // // filters.ExcludeByDefault() // true if any filter is positive // // When true, discovery should start with an empty set and add matches. // When false (all filters are negated), discovery should start with all components // and remove matches. // // ## One-Shot Usage // // // Parse and evaluate in one step // result, err := filter.Apply("./apps/* | name=web", ".", components) // // # Implementation Details // // ## Lexer // // The lexer (lexer.go) scans the input string and produces tokens: // - IDENT: Identifiers (foo, name, etc.) // - PATH: Paths (./apps/*, /absolute, etc.) // - BANG: Negation operator (!) // - PIPE: Intersection operator (|) // - EQUAL: Assignment operator (=) // - LBRACE: Left brace ({) // - RBRACE: Right brace (}) // - EOF: End of input // // ## Parser // // The parser (parser.go) uses recursive descent parsing with Pratt parsing for operators. // It produces an AST with the following node types: // - PathFilter: Path/glob filter // - AttributeFilter: Key-value attribute filter // - PrefixExpression: Negation operator // - InfixExpression: Union operator // // ## Evaluator // // The evaluator (evaluator.go) walks the AST and applies the filter logic: // - PathFilter: Uses glob matching (github.com/gobwas/glob) with eager compilation // and caching via sync.Once for performance // - AttributeFilter: Matches attributes by key-value pairs: // - name: Matches filepath.Base(component.Path) // - type: Matches component.Kind (unit or stack) // - external: Matches component.External (true or false) // - PrefixExpression: Returns the complement of the right side // - InfixExpression: Returns the intersection by applying right filter to left results // // Path filters compile their glob pattern once on first evaluation and cache // the compiled result for reuse in subsequent evaluations, providing significant // performance improvements when filters are evaluated multiple times. // // # Related // // This package implements the filter syntax described in RFC #4060: // https://github.com/gruntwork-io/terragrunt/issues/4060 // // The syntax is inspired by Turborepo's filter syntax: // https://turbo.build/repo/docs/reference/run#--filter-string // // # Future Enhancements // // Future versions will support: // - Git-based filtering ([main...HEAD]) // - Dependency traversal (name=foo...) // - Dependents traversal (...name=foo) // - Read-based filtering (reads=path/to/file) package filter ================================================ FILE: internal/filter/errors.go ================================================ package filter import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" ) // ErrorCode categorizes parse errors for hint lookup. type ErrorCode int const ( ErrorCodeUnknown ErrorCode = iota ErrorCodeUnexpectedToken ErrorCodeUnexpectedEOF ErrorCodeEmptyExpression ErrorCodeMissingClosingBracket ErrorCodeMissingClosingBrace ErrorCodeIllegalToken ErrorCodeMissingOperand ErrorCodeEmptyGitFilter ErrorCodeMissingGitRef ErrorCodeInvalidGlob ) // ParseError represents an error that occurred during parsing. type ParseError struct { // Title is a high-level error description (e.g., "Unclosed Git filter expression") Title string // Message is a detailed explanation shown at the problematic location (e.g., "this Git-based expression is missing a closing ']'") Message string // Query is the original filter query Query string // TokenLiteral is the problematic token TokenLiteral string // TokenLength is the length of the problematic token (used for underline width) TokenLength int // Position is the position of the problematic token Position int // ErrorPosition is the position to show the caret (e.g. for unclosed brackets, it points to the opening bracket) ErrorPosition int // ErrorCode is the error code, used for hint lookup ErrorCode ErrorCode } // Error returns a string representation of the error. // // We suppress the gocritic "hugeParam" warning because this is a very large struct, // but we need it to implement the error interface, not its pointer. // //nolint:gocritic func (e ParseError) Error() string { return fmt.Sprintf("Parse error at position %d: %s", e.Position, e.Message) } // NewParseError creates a new ParseError with the given message and position. func NewParseError(message string, position int) error { return errors.New(ParseError{Message: message, Position: position}) } // NewParseErrorWithContext creates a new ParseError with full context for rich diagnostics. func NewParseErrorWithContext(title, message string, position, errorPosition int, query, tokenLiteral string, tokenLength int, code ErrorCode) error { return errors.New(ParseError{ Title: title, Message: message, Position: position, ErrorPosition: errorPosition, Query: query, TokenLiteral: tokenLiteral, TokenLength: tokenLength, ErrorCode: code, }) } // EvaluationError represents an error that occurred during evaluation. type EvaluationError struct { Cause error Message string } func (e EvaluationError) Error() string { if e.Cause != nil { return fmt.Sprintf("Evaluation error: %s: %v", e.Message, e.Cause) } return "evaluation error: " + e.Message } // NewEvaluationError creates a new EvaluationError with the given message. func NewEvaluationError(message string) error { return errors.New(EvaluationError{Message: message}) } // NewEvaluationErrorWithCause creates a new EvaluationError with the given message and cause. func NewEvaluationErrorWithCause(message string, cause error) error { return errors.New(EvaluationError{Message: message, Cause: cause}) } // FilterQueryRequiresDiscoveryError is an error that is returned when a filter query requires discovery of Terragrunt configurations. type FilterQueryRequiresDiscoveryError struct { Query string } func (e FilterQueryRequiresDiscoveryError) Error() string { return fmt.Sprintf( "Filter query '%s' requires discovery of Terragrunt configurations, which is not supported when evaluating filters on generic files", e.Query, ) } ================================================ FILE: internal/filter/evaluator.go ================================================ package filter import ( "fmt" "path/filepath" "slices" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( AttributeName = "name" AttributeType = "type" AttributeExternal = "external" AttributeReading = "reading" AttributeSource = "source" AttributeTypeValueUnit = string(component.UnitKind) AttributeTypeValueStack = string(component.StackKind) AttributeExternalValueTrue = "true" AttributeExternalValueFalse = "false" // MaxTraversalDepth is the maximum depth to traverse the graph for both dependencies and dependents. MaxTraversalDepth = 1000000 ) // graphTraversalParams consolidates parameters for filter graph traversal. type graphTraversalParams struct { resultSet map[string]component.Component visited map[string]int direction GraphDirection warnOnLimit bool } // EvaluationContext provides additional context for filter evaluation, such as Git worktree directories. type EvaluationContext struct { // GitWorktrees maps Git references to temporary worktree directory paths. // This is used by GitFilter expressions to access different Git references. GitWorktrees map[string]string // WorkingDir is the base working directory for resolving relative paths. WorkingDir string } // Evaluate evaluates an expression against a list of components and returns the filtered components. // If logger is provided, it will be used for logging warnings during evaluation. func Evaluate(l log.Logger, expr Expression, components component.Components) (component.Components, error) { if expr == nil { return nil, NewEvaluationError("expression is nil") } switch node := expr.(type) { case *PathExpression: return evaluatePathFilter(node, components) case *AttributeExpression: return evaluateAttributeFilter(node, components) case *PrefixExpression: return evaluatePrefixExpression(l, node, components) case *InfixExpression: return evaluateInfixExpression(l, node, components) case *GraphExpression: return evaluateGraphExpression(l, node, components) case *GitExpression: return evaluateGitFilter(node, components) default: return nil, NewEvaluationError("unknown expression type") } } // evaluatePathFilter evaluates a path filter using glob matching. func evaluatePathFilter(filter *PathExpression, components component.Components) (component.Components, error) { result := make(component.Components, 0, len(components)) for _, c := range components { if matchPath(c, filter) { result = append(result, c) } } return result, nil } // evaluateAttributeFilter evaluates an attribute filter. func evaluateAttributeFilter(filter *AttributeExpression, components []component.Component) ([]component.Component, error) { var result []component.Component switch filter.Key { case AttributeName: g := filter.Glob() for _, c := range components { if g.Match(filepath.Base(c.Path())) { result = append(result, c) } } case AttributeType: switch filter.Value { case AttributeTypeValueUnit: for _, c := range components { if _, ok := c.(*component.Unit); ok { result = append(result, c) } } case AttributeTypeValueStack: for _, c := range components { if _, ok := c.(*component.Stack); ok { result = append(result, c) } } default: return nil, NewEvaluationError("invalid type value: " + filter.Value + " (expected 'unit' or 'stack')") } case AttributeExternal: switch filter.Value { case AttributeExternalValueTrue: for _, c := range components { if c.External() { result = append(result, c) } } case AttributeExternalValueFalse: for _, c := range components { if !c.External() { result = append(result, c) } } default: return nil, NewEvaluationError("invalid external value: " + filter.Value + " (expected 'true' or 'false')") } case AttributeReading: g := filter.Glob() for _, c := range components { if slices.ContainsFunc(c.Reading(), g.Match) { result = append(result, c) continue } discoveryCtx := c.DiscoveryContext() if discoveryCtx == nil || discoveryCtx.WorkingDir == "" { continue } relReading := make([]string, 0, len(c.Reading())) for _, reading := range c.Reading() { rel, err := filepath.Rel(c.DiscoveryContext().WorkingDir, reading) if err != nil { return nil, NewEvaluationErrorWithCause(fmt.Sprintf("failed to get relative path for component %s reading: %s", c.Path(), reading), err) } relReading = append(relReading, filepath.ToSlash(rel)) } if slices.ContainsFunc(relReading, g.Match) { result = append(result, c) } } case AttributeSource: g := filter.Glob() for _, c := range components { if slices.ContainsFunc(c.Sources(), g.Match) { result = append(result, c) } } default: return nil, NewEvaluationError("unknown attribute key: " + filter.Key) } return result, nil } // evaluatePrefixExpression evaluates a prefix expression (negation). func evaluatePrefixExpression(l log.Logger, expr *PrefixExpression, components component.Components) (component.Components, error) { if expr.Operator != "!" { return nil, NewEvaluationError("unknown prefix operator: " + expr.Operator) } toExclude, err := Evaluate(l, expr.Right, components) if err != nil { return nil, err } if len(toExclude) == 0 { return components, nil } // Build a set of paths to exclude for efficient lookup. // We compare by path rather than object identity because graph traversal // may return component instances from Dependencies()/Dependents() that are // different objects than those in the input list. excludePaths := make(map[string]struct{}, len(toExclude)) for _, c := range toExclude { excludePaths[c.Path()] = struct{}{} } // We don't use slices.DeleteFunc here because we don't want the members of the original components slice to be // zeroed. results := make(component.Components, 0, len(components)-len(toExclude)) for _, c := range components { if _, excluded := excludePaths[c.Path()]; excluded { continue } results = append(results, c) } return results, nil } // evaluateInfixExpression evaluates an infix expression (intersection). func evaluateInfixExpression(l log.Logger, expr *InfixExpression, components component.Components) (component.Components, error) { if expr.Operator != "|" { return nil, NewEvaluationError("unknown infix operator: " + expr.Operator) } leftResult, err := Evaluate(l, expr.Left, components) if err != nil { return nil, err } rightResult, err := Evaluate(l, expr.Right, leftResult) if err != nil { return nil, err } return rightResult, nil } // evaluateGraphExpression evaluates a graph expression by traversing dependency/dependent graphs. func evaluateGraphExpression(l log.Logger, expr *GraphExpression, components component.Components) (component.Components, error) { targetMatches, err := Evaluate(l, expr.Target, components) if err != nil { return nil, err } // NOTE: We previously filtered out components with OriginGraphDiscovery here to avoid // including components that were only discovered via graph relationships. However, this // caused issues with intersection filters like "service... | !^db..." where db is // discovered via the first filter and then needs to be used as a target in the second. // The discovery phase already handles this logic properly, so we don't need to filter // by origin here during filter evaluation. if len(targetMatches) == 0 { return component.Components{}, nil } resultSet := make(map[string]component.Component) if !expr.ExcludeTarget { for _, c := range targetMatches { resultSet[c.Path()] = c } } if expr.IncludeDependencies { depth := MaxTraversalDepth warnOnLimit := true if expr.DependencyDepth > 0 { depth = expr.DependencyDepth warnOnLimit = false } params := &graphTraversalParams{ resultSet: resultSet, visited: make(map[string]int), direction: GraphDirectionDependencies, warnOnLimit: warnOnLimit, } for _, target := range targetMatches { traverseGraph(l, target, params, depth) } } if expr.IncludeDependents { depth := MaxTraversalDepth warnOnLimit := true if expr.DependentDepth > 0 { depth = expr.DependentDepth warnOnLimit = false } params := &graphTraversalParams{ resultSet: resultSet, visited: make(map[string]int), direction: GraphDirectionDependents, warnOnLimit: warnOnLimit, } for _, target := range targetMatches { traverseGraph(l, target, params, depth) } } result := make(component.Components, 0, len(resultSet)) for _, c := range resultSet { result = append(result, c) } return result, nil } // evaluateGitFilter evaluates a Git filter expression by comparing components between Git references. // It returns components that were added, removed, or changed between FromRef and ToRef. func evaluateGitFilter(filter *GitExpression, components component.Components) (component.Components, error) { results := make(component.Components, 0, len(components)) for _, c := range components { discoveryCtx := c.DiscoveryContext() if discoveryCtx == nil || discoveryCtx.Ref == "" { continue } if discoveryCtx.Ref == filter.FromRef || discoveryCtx.Ref == filter.ToRef { results = append(results, c) } } return results, nil } // traverseGraph recursively traverses the graph in the specified direction (dependencies or dependents). // The visited map tracks the maximum remaining depth at which each node was visited, allowing re-traversal // when a node is reached with more remaining depth (e.g., from a closer target). // The warnOnLimit flag controls whether to log a warning when depth is exhausted (used for safety limits only). func traverseGraph( l log.Logger, c component.Component, params *graphTraversalParams, remainingDepth int, ) { if remainingDepth <= 0 { if l != nil && params.warnOnLimit { directionName := params.direction.String() l.Warnf( "Maximum %s traversal depth (%d) reached for component %s during filtering. Some %s may have been excluded from results.", directionName, MaxTraversalDepth, c.Path(), directionName, ) } return } path := c.Path() if prevDepth, seen := params.visited[path]; seen && prevDepth >= remainingDepth { return } params.visited[path] = remainingDepth var relatedComponents []component.Component if params.direction == GraphDirectionDependencies { relatedComponents = c.Dependencies() } else { relatedComponents = c.Dependents() } for _, related := range relatedComponents { relatedPath := related.Path() // It's not clear why this isn't necessary. It might be in the future. // Tests pass without it, however, so we'll leave it out for now. // // Needs more investigation. // // relatedCtx := related.DiscoveryContext() // if relatedCtx != nil { // origin := relatedCtx.Origin() // if origin != component.OriginGraphDiscovery { // l.Debugf( // "Skipping %s %s in graph expression traversal: component was discovered via %s, not graph discovery", // direction.String(), // relatedPath, // origin, // ) // continue // } // } params.resultSet[relatedPath] = related traverseGraph(l, related, params, remainingDepth-1) } } ================================================ FILE: internal/filter/evaluator_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEvaluate_PathFilter(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), component.NewUnit("./apps/subdir/nested"), } for _, c := range components { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } tests := []struct { name string filter *filter.PathExpression expected []component.Component }{ { name: "exact path match", filter: mustPath(t, "./apps/app1"), expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "glob with single wildcard", filter: mustPath(t, "./apps/*"), expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "glob with single wildcard and partial match", filter: mustPath(t, "./apps/app*"), expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "glob with recursive wildcard", filter: mustPath(t, "./apps/**"), expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/subdir/nested").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "no matches", filter: mustPath(t, "./nonexistent/*"), expected: []component.Component{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.filter, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_AttributeFilter(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app"), component.NewUnit("./libs/app"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), component.NewStack("./libs/api"), } tests := []struct { name string filter *filter.AttributeExpression expected []component.Component }{ { name: "name filter single match", filter: mustAttr(t, "name", "db"), expected: []component.Component{ component.NewUnit("./libs/db"), }, }, { name: "name filter multiple matches", filter: mustAttr(t, "name", "app"), expected: []component.Component{ component.NewUnit("./apps/app"), component.NewUnit("./libs/app"), }, }, { name: "name filter no matches", filter: mustAttr(t, "name", "nonexistent"), expected: []component.Component{}, }, { name: "type filter unit", filter: mustAttr(t, "type", "unit"), expected: []component.Component{ component.NewUnit("./apps/app"), component.NewUnit("./libs/app"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), }, }, { name: "type filter stack", filter: mustAttr(t, "type", "stack"), expected: []component.Component{ component.NewStack("./libs/api"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.filter, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_AttributeFilter_InvalidKey(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app"), } attrFilter := mustAttr(t, "invalid", "foo") l := log.New() result, err := filter.Evaluate(l, attrFilter, components) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "unknown attribute key") } func TestEvaluate_AttributeFilter_Reading(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1").WithReading("shared.hcl", "shared.tfvars"), component.NewUnit("./apps/app2").WithReading("shared.hcl", "common/variables.hcl"), component.NewUnit("./apps/app3").WithReading("config.yaml", "settings.json"), component.NewUnit("./libs/db").WithReading("database.hcl"), component.NewUnit("./libs/api").WithReading(), component.NewUnit("./apps/app4").WithReading("shared.hcl", "shared.tfvars", "extra.hcl"), } tests := []struct { name string filter *filter.AttributeExpression expected []component.Component }{ { name: "exact file path match - single match", filter: mustAttr(t, "reading", "database.hcl"), expected: []component.Component{ component.NewUnit("./libs/db").WithReading("database.hcl"), }, }, { name: "exact file path match - multiple matches", filter: mustAttr(t, "reading", "shared.hcl"), expected: []component.Component{ component.NewUnit("./apps/app1").WithReading("shared.hcl", "shared.tfvars"), component.NewUnit("./apps/app2").WithReading("shared.hcl", "common/variables.hcl"), component.NewUnit("./apps/app4").WithReading("shared.hcl", "shared.tfvars", "extra.hcl"), }, }, { name: "exact file path match - no matches", filter: mustAttr(t, "reading", "nonexistent.hcl"), expected: []component.Component{}, }, { name: "glob pattern with single wildcard - *.hcl", filter: mustAttr(t, "reading", "*.hcl"), expected: []component.Component{ component.NewUnit("./apps/app1").WithReading("shared.hcl", "shared.tfvars"), component.NewUnit("./apps/app2").WithReading("shared.hcl", "common/variables.hcl"), component.NewUnit("./libs/db").WithReading("database.hcl"), component.NewUnit("./apps/app4").WithReading("shared.hcl", "shared.tfvars", "extra.hcl"), }, }, { name: "glob pattern with prefix - shared*", filter: mustAttr(t, "reading", "shared*"), expected: []component.Component{ component.NewUnit("./apps/app1").WithReading("shared.hcl", "shared.tfvars"), component.NewUnit("./apps/app2").WithReading("shared.hcl", "common/variables.hcl"), component.NewUnit("./apps/app4").WithReading("shared.hcl", "shared.tfvars", "extra.hcl"), }, }, { name: "glob pattern with double wildcard - **/variables.hcl", filter: mustAttr(t, "reading", "**/variables.hcl"), expected: []component.Component{ component.NewUnit("./apps/app2").WithReading("shared.hcl", "common/variables.hcl"), }, }, { name: "empty Reading slice - no matches", filter: mustAttr(t, "reading", "*.hcl"), expected: []component.Component{}, }, { name: "glob pattern with question mark - config.???l", filter: mustAttr(t, "reading", "config.???l"), expected: []component.Component{ component.NewUnit("./apps/app3").WithReading("config.yaml", "settings.json"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var testComponents []component.Component if tt.name == "empty Reading slice - no matches" { testComponents = []component.Component{ component.NewUnit("./libs/api").WithReading(), } } else { testComponents = components } l := log.New() result, err := filter.Evaluate(l, tt.filter, testComponents) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_AttributeFilter_Source(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1").WithConfig( &config.TerragruntConfig{ Terraform: &config.TerraformConfig{ Source: helpers.PointerTo("github.com/acme/foo"), }, }, ), component.NewUnit("./apps/app2").WithConfig( &config.TerragruntConfig{ Terraform: &config.TerraformConfig{ Source: helpers.PointerTo("git::git@github.com:acme/bar?ref=v1.0.0"), }, }, ), } tests := []struct { name string filter *filter.AttributeExpression expected []component.Component }{ { name: "glob pattern with single wildcard - github.com/acme/*", filter: mustAttr(t, "source", "github.com/acme/*"), expected: []component.Component{ components[0], }, }, { name: "glob pattern with double wildcard - git::git@github.com:acme/**", filter: mustAttr(t, "source", "git::git@github.com:acme/**"), expected: []component.Component{ components[1], }, }, { name: "glob pattern with double wildcard - **github.com**", filter: mustAttr(t, "source", "**github.com**"), expected: []component.Component{ components[0], components[1], }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.filter, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_AttributeFilter_Reading_ComponentAddedOnlyOnce(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1").WithReading("shared.hcl", "shared.tfvars", "shared.yaml"), } // This glob should match multiple files in the Reading slice, but component should only be added once attrFilter := mustAttr(t, "reading", "shared*") l := log.New() result, err := filter.Evaluate(l, attrFilter, components) require.NoError(t, err) // Should only have one component even though three files matched assert.Len(t, result, 1) assert.Equal(t, "./apps/app1", result[0].Path()) } func TestEvaluate_PrefixExpression(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), component.NewUnit("./libs/db"), } for _, c := range components { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } tests := []struct { name string expr *filter.PrefixExpression expected []component.Component }{ { name: "exclude by name", expr: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "legacy"), }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "exclude by path", expr: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "./apps/legacy"), }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "exclude by glob", expr: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "./apps/*"), }, expected: []component.Component{ component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "exclude all (double negation effect)", expr: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "type", "unit"), }, expected: []component.Component{}, }, { name: "exclude nothing", expr: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "nonexistent"), }, expected: components, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.expr, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_InfixExpression(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), } for _, c := range components { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } tests := []struct { name string expr *filter.InfixExpression expected []component.Component }{ { name: "intersection of path and name", expr: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: mustAttr(t, "name", "app1"), }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "intersection with no overlap", expr: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: mustAttr(t, "name", "db"), }, expected: []component.Component{}, }, { name: "intersection of exact path and name", expr: &filter.InfixExpression{ Left: mustPath(t, "./apps/app1"), Operator: "|", Right: mustAttr(t, "name", "app1"), }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "intersection of empty results", expr: &filter.InfixExpression{ Left: mustAttr(t, "name", "nonexistent1"), Operator: "|", Right: mustAttr(t, "name", "app1"), }, expected: []component.Component{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.expr, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_ComplexExpressions(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), component.NewUnit("./special/unit"), } for _, c := range components { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } tests := []struct { name string expr filter.Expression expected []component.Component }{ { name: "intersection with negation (refinement)", expr: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "legacy"), }, }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "negated intersection", expr: &filter.PrefixExpression{ Operator: "!", Right: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: mustAttr(t, "name", "app1"), }, }, expected: []component.Component{ component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./special/unit").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "chained intersections (multiple refinements)", expr: &filter.InfixExpression{ Left: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: &filter.PrefixExpression{Operator: "!", Right: mustAttr(t, "name", "legacy")}, }, Operator: "|", Right: mustAttr(t, "name", "app1"), }, expected: []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Evaluate(l, tt.expr, components) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestEvaluate_EdgeCases(t *testing.T) { t.Parallel() t.Run("nil expression", func(t *testing.T) { t.Parallel() components := []component.Component{component.NewUnit("./app")} l := log.New() result, err := filter.Evaluate(l, nil, components) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "expression is nil") }) t.Run("empty components list", func(t *testing.T) { t.Parallel() expr := mustAttr(t, "name", "foo") l := log.New() result, err := filter.Evaluate(l, expr, []component.Component{}) require.NoError(t, err) assert.Empty(t, result) }) t.Run("invalid glob pattern", func(t *testing.T) { t.Parallel() _, err := filter.NewPathFilter("[invalid-glob") require.Error(t, err) }) } func TestEvaluate_GraphExpression(t *testing.T) { t.Parallel() // Create a component graph: vpc -> db -> app tests := []struct { expr *filter.GraphExpression setup func() []component.Component name string expected []string }{ { name: "dependency traversal - app...", expr: &filter.GraphExpression{ Target: mustAttr(t, "name", "app"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, }, expected: []string{"./app", "./db", "./vpc"}, setup: func() []component.Component { vpcCtx := &component.DiscoveryContext{} vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) dbCtx := &component.DiscoveryContext{} dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) return []component.Component{vpc, db, app} }, }, { name: "dependent traversal - ...vpc", expr: &filter.GraphExpression{ Target: mustAttr(t, "name", "vpc"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, }, expected: []string{"./vpc", "./db", "./app"}, setup: func() []component.Component { vpc := component.NewUnit("./vpc") dbCtx := &component.DiscoveryContext{} dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) appCtx := &component.DiscoveryContext{} appCtx.SuggestOrigin(component.OriginGraphDiscovery) app := component.NewUnit("./app").WithDiscoveryContext(appCtx) app.AddDependency(db) db.AddDependency(vpc) return []component.Component{vpc, db, app} }, }, { name: "both directions - ...db...", expr: &filter.GraphExpression{ Target: mustAttr(t, "name", "db"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, }, expected: []string{"./db", "./vpc", "./app"}, setup: func() []component.Component { vpcCtx := &component.DiscoveryContext{} vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) db := component.NewUnit("./db") appCtx := &component.DiscoveryContext{} appCtx.SuggestOrigin(component.OriginGraphDiscovery) app := component.NewUnit("./app").WithDiscoveryContext(appCtx) app.AddDependency(db) db.AddDependency(vpc) return []component.Component{vpc, db, app} }, }, { name: "exclude target - ^app...", expr: &filter.GraphExpression{ Target: mustAttr(t, "name", "app"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: true, }, expected: []string{"./db", "./vpc"}, setup: func() []component.Component { vpcCtx := &component.DiscoveryContext{} vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) dbCtx := &component.DiscoveryContext{} dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) return []component.Component{vpc, db, app} }, }, { name: "exclude target with dependents - ...^db...", expr: &filter.GraphExpression{ Target: mustAttr(t, "name", "db"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: true, }, expected: []string{"./vpc", "./app"}, setup: func() []component.Component { vpcCtx := &component.DiscoveryContext{} vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) db := component.NewUnit("./db") appCtx := &component.DiscoveryContext{} appCtx.SuggestOrigin(component.OriginGraphDiscovery) app := component.NewUnit("./app").WithDiscoveryContext(appCtx) app.AddDependency(db) db.AddDependency(vpc) return []component.Component{vpc, db, app} }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() components := tt.setup() expected := make([]component.Component, 0, len(tt.expected)) expectedMap := make(map[string]bool) for _, path := range tt.expected { expectedMap[path] = true } for _, c := range components { if expectedMap[c.Path()] { expected = append(expected, c) } } l := log.New() result, err := filter.Evaluate(l, tt.expr, components) require.NoError(t, err) assert.ElementsMatch(t, expected, result) }) } } func TestEvaluate_GraphExpression_ComplexGraph(t *testing.T) { t.Parallel() // Create a more complex graph: // vpc -> [db, cache] -> app t.Run("dependency traversal from app finds all dependencies", func(t *testing.T) { t.Parallel() vpcCtx := &component.DiscoveryContext{} vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) dbCtx := &component.DiscoveryContext{} dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) cacheCtx := &component.DiscoveryContext{} cacheCtx.SuggestOrigin(component.OriginGraphDiscovery) cache := component.NewUnit("./cache").WithDiscoveryContext(cacheCtx) app := component.NewUnit("./app") app.AddDependency(db) app.AddDependency(cache) db.AddDependency(vpc) cache.AddDependency(vpc) components := []component.Component{vpc, db, cache, app} expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "app"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{app, db, cache, vpc}, result) }) t.Run("dependent traversal from vpc finds all dependents", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") dbCtx := &component.DiscoveryContext{} dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) cacheCtx := &component.DiscoveryContext{} cacheCtx.SuggestOrigin(component.OriginGraphDiscovery) cache := component.NewUnit("./cache").WithDiscoveryContext(cacheCtx) appCtx := &component.DiscoveryContext{} appCtx.SuggestOrigin(component.OriginGraphDiscovery) app := component.NewUnit("./app").WithDiscoveryContext(appCtx) app.AddDependency(db) app.AddDependency(cache) db.AddDependency(vpc) cache.AddDependency(vpc) components := []component.Component{vpc, db, cache, app} expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "vpc"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{vpc, db, cache, app}, result) }) } func TestEvaluate_GraphExpression_EmptyResults(t *testing.T) { t.Parallel() components := []component.Component{ component.NewUnit("./app"), component.NewUnit("./db"), } t.Run("target doesn't match any component", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "nonexistent"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.Empty(t, result) }) } func TestEvaluate_GraphExpression_NoDependencies(t *testing.T) { t.Parallel() // Components with no dependencies or dependents isolated := component.NewUnit("./isolated") another := component.NewUnit("./another") components := []component.Component{isolated, another} t.Run("component with no dependencies", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "isolated"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{isolated}, result) }) t.Run("component with no dependents", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "isolated"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{isolated}, result) }) } func TestEvaluate_GraphExpression_CircularDependencies(t *testing.T) { t.Parallel() // Create a circular dependency: a -> b -> a // The traversal should not infinite loop a := component.NewUnit("./a") bCtx := &component.DiscoveryContext{} bCtx.SuggestOrigin(component.OriginGraphDiscovery) b := component.NewUnit("./b").WithDiscoveryContext(bCtx) a.AddDependency(b) b.AddDependency(a) components := []component.Component{a, b} t.Run("circular dependency - dependency traversal stops at cycle", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "a"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) // Should include both a and b, but not loop infinitely assert.ElementsMatch(t, []component.Component{a, b}, result) assert.Len(t, result, 2) }) t.Run("circular dependency - dependent traversal stops at cycle", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "a"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) // Should include both a and b, but not loop infinitely assert.ElementsMatch(t, []component.Component{a, b}, result) assert.Len(t, result, 2) }) } func TestEvaluate_GraphExpression_WithPathFilter(t *testing.T) { t.Parallel() vpcCtx := &component.DiscoveryContext{ WorkingDir: ".", } vpcCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc := component.NewUnit("./vpc").WithDiscoveryContext(vpcCtx) dbCtx := &component.DiscoveryContext{ WorkingDir: ".", } dbCtx.SuggestOrigin(component.OriginGraphDiscovery) db := component.NewUnit("./db").WithDiscoveryContext(dbCtx) app := component.NewUnit("./app").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) app.AddDependency(db) db.AddDependency(vpc) components := []component.Component{vpc, db, app} t.Run("graph expression with path filter target", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustPath(t, "./app"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{app, db, vpc}, result) }) } func TestEvaluate_GraphExpression_DepthLimited(t *testing.T) { t.Parallel() // Create a component graph: a -> b -> c -> d a := component.NewUnit("./a") b := component.NewUnit("./b") c := component.NewUnit("./c") d := component.NewUnit("./d") // Set up dependencies: d depends on c, c depends on b, b depends on a d.AddDependency(c) c.AddDependency(b) b.AddDependency(a) components := []component.Component{a, b, c, d} t.Run("depth 1 dependency traversal from d", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "d"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, DependencyDepth: 1, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{d, c}, result) }) t.Run("depth 2 dependency traversal from d", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "d"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, DependencyDepth: 2, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{d, c, b}, result) }) t.Run("depth 1 dependent traversal from a", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "a"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, DependentDepth: 1, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{a, b}, result) }) t.Run("depth 2 dependent traversal from a", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "a"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, DependentDepth: 2, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{a, b, c}, result) }) t.Run("unlimited depth (0) traverses all", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: mustAttr(t, "name", "d"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, DependencyDepth: 0, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.ElementsMatch(t, []component.Component{d, c, b, a}, result) }) } func TestEvaluate_GraphExpression_DepthLimited_MultipleTargets(t *testing.T) { t.Parallel() // Graph structure: // targetA (2 hops from shared) --> intermediate --> shared --> deep1 --> deep2 // targetB (1 hop from shared) --> shared // // With depth=2: // - From targetA: can reach intermediate, shared (2 hops) // - From targetB: can reach shared, deep1 (2 hops) // - Result should include deep1 even though targetA reaches shared first with less remaining depth ctx := &component.DiscoveryContext{WorkingDir: "."} targetA := component.NewUnit("./targetA").WithDiscoveryContext(ctx) targetB := component.NewUnit("./targetB").WithDiscoveryContext(ctx) intermediate := component.NewUnit("./intermediate").WithDiscoveryContext(ctx) shared := component.NewUnit("./shared").WithDiscoveryContext(ctx) deep1 := component.NewUnit("./deep1").WithDiscoveryContext(ctx) deep2 := component.NewUnit("./deep2").WithDiscoveryContext(ctx) // Set up dependencies targetA.AddDependency(intermediate) intermediate.AddDependency(shared) targetB.AddDependency(shared) shared.AddDependency(deep1) deep1.AddDependency(deep2) components := []component.Component{targetA, targetB, intermediate, shared, deep1, deep2} t.Run("multiple targets with shared dependency at different distances", func(t *testing.T) { t.Parallel() // Match both targetA and targetB using glob expr := &filter.GraphExpression{ Target: mustPath(t, "./target*"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, DependencyDepth: 2, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) // Should include: targetA, targetB, intermediate (1 hop from A), shared (2 hops from A, 1 from B), deep1 (2 hops from B) // Should NOT include: deep2 (3 hops from B, too deep) assert.ElementsMatch(t, []component.Component{targetA, targetB, intermediate, shared, deep1}, result) }) } func TestEvaluate_GitFilter(t *testing.T) { t.Parallel() tests := []struct { name string fromRef string toRef string setup func() []component.Component expected []component.Component wantError bool }{ { name: "components without DiscoveryContext are filtered out", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { return []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./libs/db"), } }, expected: []component.Component{}, }, { name: "components with empty Ref are filtered out", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: ""}) app2 := component.NewUnit("./apps/app2") app2.SetDiscoveryContext(&component.DiscoveryContext{Ref: ""}) return []component.Component{app1, app2} }, expected: []component.Component{}, }, { name: "components with Ref matching FromRef are included", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: "main"}) app2 := component.NewUnit("./apps/app2") app2.SetDiscoveryContext(&component.DiscoveryContext{Ref: "feature"}) db := component.NewUnit("./libs/db") db.SetDiscoveryContext(&component.DiscoveryContext{Ref: "main"}) return []component.Component{app1, app2, db} }, expected: []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./libs/db"), }, }, { name: "components with Ref matching ToRef are included", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: "HEAD"}) app2 := component.NewUnit("./apps/app2") app2.SetDiscoveryContext(&component.DiscoveryContext{Ref: "feature"}) db := component.NewUnit("./libs/db") db.SetDiscoveryContext(&component.DiscoveryContext{Ref: "HEAD"}) return []component.Component{app1, app2, db} }, expected: []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./libs/db"), }, }, { name: "components with Ref matching either FromRef or ToRef are included", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: "main"}) app2 := component.NewUnit("./apps/app2") app2.SetDiscoveryContext(&component.DiscoveryContext{Ref: "HEAD"}) db := component.NewUnit("./libs/db") db.SetDiscoveryContext(&component.DiscoveryContext{Ref: "feature"}) api := component.NewUnit("./libs/api") api.SetDiscoveryContext(&component.DiscoveryContext{Ref: "main"}) return []component.Component{app1, app2, db, api} }, expected: []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./libs/api"), }, }, { name: "components with Ref not matching either are filtered out", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: "feature"}) app2 := component.NewUnit("./apps/app2") app2.SetDiscoveryContext(&component.DiscoveryContext{Ref: "develop"}) db := component.NewUnit("./libs/db") db.SetDiscoveryContext(&component.DiscoveryContext{Ref: "release"}) return []component.Component{app1, app2, db} }, expected: []component.Component{}, }, { name: "mixed components with and without DiscoveryContext", fromRef: "main", toRef: "HEAD", setup: func() []component.Component { app1 := component.NewUnit("./apps/app1") app1.SetDiscoveryContext(&component.DiscoveryContext{Ref: "main"}) app2 := component.NewUnit("./apps/app2") // No DiscoveryContext set db := component.NewUnit("./libs/db") db.SetDiscoveryContext(&component.DiscoveryContext{Ref: "HEAD"}) api := component.NewUnit("./libs/api") api.SetDiscoveryContext(&component.DiscoveryContext{Ref: ""}) return []component.Component{app1, app2, db, api} }, expected: []component.Component{ component.NewUnit("./apps/app1"), component.NewUnit("./libs/db"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() gitFilter := filter.NewGitExpression(tt.fromRef, tt.toRef) components := tt.setup() l := log.New() result, err := filter.Evaluate(l, gitFilter, components) if tt.wantError { require.Error(t, err) assert.Nil(t, result) } else { require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } expectedPaths := make([]string, len(tt.expected)) for i, c := range tt.expected { expectedPaths[i] = c.Path() } assert.ElementsMatch(t, expectedPaths, resultPaths) } }) } } func TestEvaluate_GitFilterString(t *testing.T) { t.Parallel() tests := []struct { name string filter *filter.GitExpression expected string }{ { name: "two references", filter: filter.NewGitExpression("main", "HEAD"), expected: "[main...HEAD]", }, { name: "commit SHA references", filter: filter.NewGitExpression("abc123", "def456"), expected: "[abc123...def456]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.filter.String()) }) } } func TestGitFilter_RequiresDiscovery(t *testing.T) { t.Parallel() gitFilter := filter.NewGitExpression("main", "HEAD") expr, requires := gitFilter.RequiresDiscovery() assert.True(t, requires) assert.Equal(t, gitFilter, expr) } func TestGitFilter_RequiresParse(t *testing.T) { t.Parallel() gitFilter := filter.NewGitExpression("main", "HEAD") expr, requires := gitFilter.RequiresParse() assert.False(t, requires) assert.Nil(t, expr) } // TestEvaluate_GraphExpressionWithGitExpressionTarget tests evaluating GraphExpressions // where the target is a GitExpression. // // e.g. // `... [main...commit] ...` func TestEvaluate_GraphExpressionWithGitExpressionTarget(t *testing.T) { t.Parallel() t.Run("dependencies of git-changed component", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./db", "./vpc"}, resultPaths, "Should include db (git-matched) and vpc (its dependency)", ) }) t.Run("dependents of git-changed component", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./db", "./app"}, resultPaths, "Should include db (git-matched) and app (its dependent)", ) }) t.Run("both directions of git-changed component", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./vpc", "./db", "./app"}, resultPaths, "Should include db (git-matched), vpc (dependency), and app (dependent)", ) }) t.Run("no components match git filter - returns empty", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) assert.Empty(t, result, "Should return empty when no components match git filter") }) t.Run("multiple git-changed components with shared dependencies", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") cache := component.NewUnit("./cache") app := component.NewUnit("./app") app.AddDependency(db) app.AddDependency(cache) db.AddDependency(vpc) cache.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) discoveryCtx = &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) cache.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, cache, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./vpc", "./db", "./cache", "./app"}, resultPaths, "Should include all components connected to git-changed components", ) }) t.Run("exclude target with git expression", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: true, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./vpc"}, resultPaths, "Should include only vpc (dependency), excluding db (target)", ) }) t.Run("git-changed component with Ref matching FromRef", func(t *testing.T) { t.Parallel() vpc := component.NewUnit("./vpc") db := component.NewUnit("./db") app := component.NewUnit("./app") app.AddDependency(db) db.AddDependency(vpc) discoveryCtx := &component.DiscoveryContext{Ref: "main"} discoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) db.SetDiscoveryContext(discoveryCtx) graphDiscoveryCtx := &component.DiscoveryContext{} graphDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) vpc.SetDiscoveryContext(graphDiscoveryCtx) app.SetDiscoveryContext(graphDiscoveryCtx) components := []component.Component{vpc, db, app} expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, c := range result { resultPaths[i] = c.Path() } assert.ElementsMatch( t, []string{"./vpc", "./db", "./app"}, resultPaths, "Should include components when Ref matches FromRef", ) }) } // TestEvaluate_GraphExpressionWithGitTarget_DependencyChain tests that dependency // traversal works correctly through a chain when the starting component is git-matched. func TestEvaluate_GraphExpressionWithGitTarget_DependencyChain(t *testing.T) { t.Parallel() // Create a longer chain: a -> b -> c -> d -> e a := component.NewUnit("./a") b := component.NewUnit("./b") c := component.NewUnit("./c") d := component.NewUnit("./d") e := component.NewUnit("./e") a.AddDependency(b) b.AddDependency(c) c.AddDependency(d) d.AddDependency(e) aDiscoveryCtx := &component.DiscoveryContext{} aDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) a.SetDiscoveryContext(aDiscoveryCtx) bDiscoveryCtx := &component.DiscoveryContext{} bDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) b.SetDiscoveryContext(bDiscoveryCtx) cDiscoveryCtx := &component.DiscoveryContext{Ref: "HEAD"} cDiscoveryCtx.SuggestOrigin(component.OriginWorktreeDiscovery) c.SetDiscoveryContext(cDiscoveryCtx) dDiscoveryCtx := &component.DiscoveryContext{} dDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) d.SetDiscoveryContext(dDiscoveryCtx) eDiscoveryCtx := &component.DiscoveryContext{} eDiscoveryCtx.SuggestOrigin(component.OriginGraphDiscovery) e.SetDiscoveryContext(eDiscoveryCtx) components := []component.Component{a, b, c, d, e} t.Run("dependencies traverse the full chain", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, comp := range result { resultPaths[i] = comp.Path() } assert.ElementsMatch(t, []string{"./c", "./d", "./e"}, resultPaths) }) t.Run("dependents traverse the full chain", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, comp := range result { resultPaths[i] = comp.Path() } t.Logf("Result paths: %v", resultPaths) assert.ElementsMatch(t, []string{"./a", "./b", "./c"}, resultPaths) }) t.Run("both directions traverse the full graph", func(t *testing.T) { t.Parallel() expr := &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, } l := log.New() result, err := filter.Evaluate(l, expr, components) require.NoError(t, err) resultPaths := make([]string, len(result)) for i, comp := range result { resultPaths[i] = comp.Path() } t.Logf("Result paths: %v", resultPaths) assert.ElementsMatch( t, []string{"./a", "./b", "./c", "./d", "./e"}, resultPaths, ) }) } ================================================ FILE: internal/filter/examples_test.go ================================================ package filter_test import ( "fmt" "path/filepath" "sort" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" ) // exampleLogger creates a logger for examples with a proper formatter. func exampleLogger() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithFormatter(formatter)) } // Example_basicPathFilter demonstrates filtering components by path with a glob pattern. func Example_basicPathFilter() { components := []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } l := log.New() result, _ := filter.Apply(l, "./apps/*", components) for _, c := range result { fmt.Println(filepath.Base(c.Path())) } // Output: // app1 // app2 } // Example_attributeFilter demonstrates filtering components by name attribute. func Example_attributeFilter() { components := []component.Component{ component.NewUnit("./apps/frontend").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/backend").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } l := log.New() result, _ := filter.Apply(l, "name=api", components) for _, c := range result { fmt.Println(c.Path()) } // Output: // ./services/api } // Example_exclusionFilter demonstrates excluding components using the negation operator. func Example_exclusionFilter() { components := []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } l := log.New() result, _ := filter.Apply(l, "!legacy", components) for _, c := range result { fmt.Println(filepath.Base(c.Path())) } // Output: // app1 // app2 } // Example_intersectionFilter demonstrates refining results with the intersection operator. func Example_intersectionFilter() { components := []component.Component{ component.NewUnit("./apps/frontend").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/backend").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } // Select components in ./apps/ that are named "frontend" l := log.New() result, _ := filter.Apply(l, "./apps/* | frontend", components) for _, c := range result { fmt.Println(filepath.Base(c.Path())) } // Output: // frontend } // Example_complexQuery demonstrates a complex filter combining paths and negation. func Example_complexQuery() { components := []component.Component{ component.NewUnit("./services/web").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/worker").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/cache").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } // Select all services except worker l := log.New() result, _ := filter.Apply(l, "./services/* | !worker", components) for _, c := range result { fmt.Println(filepath.Base(c.Path())) } // Output: // web } // Example_parseAndEvaluate demonstrates the two-step process of parsing and evaluating. func Example_parseAndEvaluate() { components := []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } // Parse the filter once f, err := filter.Parse("app1") if err != nil { fmt.Println("Parse error:", err) return } // Evaluate multiple times with different config sets l := log.New() result1, _ := f.Evaluate(l, components) fmt.Printf("Found %d components\n", len(result1)) // You can also inspect the original query fmt.Printf("Original query: %s\n", f.String()) // Output: // Found 1 components // Original query: app1 } // Example_recursiveWildcard demonstrates using recursive wildcards to match nested paths. func Example_recursiveWildcard() { components := []component.Component{ component.NewUnit("./infrastructure/networking/vpc").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./infrastructure/networking/subnets").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./infrastructure/compute/app-server").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } // Match all infrastructure components at any depth l := log.New() result, _ := filter.Apply(l, "./infrastructure/**", components) for _, c := range result { fmt.Println(filepath.Base(c.Path())) } // Output: // vpc // subnets // app-server } // Example_errorHandling demonstrates handling parsing errors. func Example_errorHandling() { // Invalid syntax - missing value after = _, err := filter.Parse("name=") if err != nil { fmt.Println("Error occurred") } // Valid syntax _, err = filter.Parse("name=foo") if err == nil { fmt.Println("Successfully parsed") } // Output: // Error occurred // Successfully parsed } // Example_multipleFilters demonstrates using multiple filters with union semantics. func Example_multipleFilters() { components := []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } l := exampleLogger() // Parse multiple filters - results are unioned filters, _ := filter.ParseFilterQueries(l, []string{ "./apps/*", "name=db", }) result, _ := filters.Evaluate(l, components) // Sort for consistent output names := make([]string, len(result)) for i, c := range result { names[i] = filepath.Base(c.Path()) } sort.Strings(names) for _, name := range names { fmt.Println(name) } // Output: // app1 // app2 // db } ================================================ FILE: internal/filter/filter.go ================================================ package filter import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Filter represents a parsed filter query that can be evaluated against discovered configs. type Filter struct { expr Expression originalQuery string } // Parse parses a filter query string and returns a Filter object. // Returns an error if the query cannot be parsed. func Parse(filterString string) (*Filter, error) { lexer := NewLexer(filterString) parser := NewParser(lexer) expr, err := parser.ParseExpression() if err != nil { return nil, err } return &Filter{ expr: expr, originalQuery: filterString, }, nil } // NewFilter creates a new Filter object. func NewFilter(expr Expression, originalQuery string) *Filter { return &Filter{expr: expr, originalQuery: originalQuery} } // String returns a string representation of the filter. func (f *Filter) String() string { return f.originalQuery } // Evaluate applies the filter to a list of components and returns the filtered result. // If logger is provided, it will be used for logging warnings during evaluation. func (f *Filter) Evaluate(l log.Logger, components component.Components) (component.Components, error) { return Evaluate(l, f.expr, components) } // Expression returns the parsed AST expression. // This is useful for debugging or advanced use cases. func (f *Filter) Expression() Expression { return f.expr } // RequiresParse returns true if the filter requires parsing of Terragrunt HCL configurations. func (f *Filter) RequiresParse() (Expression, bool) { return f.expr.RequiresParse() } // Negated returns the equivalent filter with negation flipped. // // If the filter is already negated, it will return the non-negated filter. func (f *Filter) Negated() *Filter { switch node := f.expr.(type) { case *PrefixExpression: return NewFilter(node.Right, f.originalQuery) case *InfixExpression: return NewFilter( NewInfixExpression( node.Left.Negated(), node.Operator, node.Right, ), f.originalQuery, ) default: return f } } // Apply is a convenience function that parses and evaluates a filter in one step. // It's equivalent to calling Parse followed by Evaluate. func Apply(l log.Logger, filterString string, components component.Components) (component.Components, error) { filter, err := Parse(filterString) if err != nil { return nil, err } return filter.Evaluate(l, components) } ================================================ FILE: internal/filter/filter_test.go ================================================ package filter_test import ( "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var testComponents = []component.Component{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/web").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/worker").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } func TestFilter_ParseAndEvaluate(t *testing.T) { t.Parallel() tests := []struct { name string filterString string expected component.Components expectError bool }{ { name: "simple name filter", filterString: "app1", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "attribute filter", filterString: "name=db", expected: component.Components{ component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "path filter with wildcard", filterString: "./apps/*", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/legacy").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "negated filter", filterString: "!legacy", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/web").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/worker").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "intersection of path and name", filterString: "./apps/* | app1", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "intersection with negation", filterString: "./apps/* | !legacy", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "chained intersections", filterString: "./apps/* | !legacy | app1", expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "recursive wildcard", filterString: "./services/**", expected: component.Components{ component.NewUnit("./services/web").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./services/worker").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "parse error - empty", filterString: "", expected: nil, expectError: true, }, { name: "parse error - invalid syntax", filterString: "foo |", expected: nil, expectError: true, }, { name: "parse error - incomplete expression", filterString: "name=", expected: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() filter, err := filter.Parse(tt.filterString) if tt.expectError { require.Error(t, err) assert.Nil(t, filter) return } require.NoError(t, err) require.NotNil(t, filter) logger := log.New() result, err := filter.Evaluate(logger, testComponents) require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) // Verify String() returns original query assert.Equal(t, tt.filterString, filter.String()) }) } } func TestFilter_Apply(t *testing.T) { t.Parallel() tests := []struct { name string filterString string components component.Components expected component.Components expectError bool }{ { name: "apply with simple filter", filterString: "app1", components: testComponents, expected: component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "apply with path filter", filterString: "./libs/*", components: testComponents, expected: component.Components{ component.NewUnit("./libs/db").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./libs/api").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), }, }, { name: "apply with empty components", filterString: "anything", components: component.Components{}, expected: component.Components{}, }, { name: "apply with parse error", filterString: "!", components: testComponents, expected: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Apply(l, tt.filterString, tt.components) if tt.expectError { require.Error(t, err) assert.Nil(t, result) return } require.NoError(t, err) assert.ElementsMatch(t, tt.expected, result) }) } } func TestFilter_Expression(t *testing.T) { t.Parallel() filterString := "name=foo" f, err := filter.Parse(filterString) require.NoError(t, err) expr := f.Expression() assert.NotNil(t, expr) // Verify it's the correct type attrFilter, ok := expr.(*filter.AttributeExpression) assert.True(t, ok) assert.Equal(t, "name", attrFilter.Key) assert.Equal(t, "foo", attrFilter.Value) } func TestFilter_RealWorldScenarios(t *testing.T) { t.Parallel() repoComponents := []component.Component{ component.NewUnit("./infrastructure/networking/vpc"), component.NewUnit("./infrastructure/networking/subnets"), component.NewUnit("./infrastructure/networking/security-groups"), component.NewUnit("./infrastructure/compute/app-server"), component.NewUnit("./infrastructure/compute/db-server"), component.NewUnit("./apps/frontend"), component.NewUnit("./apps/backend"), component.NewUnit("./apps/api"), component.NewUnit("./test/test-app"), } for _, c := range repoComponents { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } tests := []struct { name string filterString string description string expected []string }{ { name: "all networking infrastructure", filterString: "./infrastructure/networking/*", description: "Select all networking-related units", expected: []string{"vpc", "subnets", "security-groups"}, }, { name: "apps excluding test-app", filterString: "./apps/* | !test-app", description: "Select all apps except test-app", expected: []string{"frontend", "backend", "api"}, }, { name: "compute infrastructure excluding db-server", filterString: "./infrastructure/compute/* | !db-server", description: "Select compute infrastructure except db-server", expected: []string{"app-server"}, }, { name: "everything in infrastructure", filterString: "./infrastructure/**", description: "Select all infrastructure units recursively", expected: []string{"vpc", "subnets", "security-groups", "app-server", "db-server"}, }, { name: "exclude specific unit", filterString: "!test-app", description: "Exclude test-app from all units", expected: []string{"vpc", "subnets", "security-groups", "app-server", "db-server", "frontend", "backend", "api"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Apply(l, tt.filterString, repoComponents) require.NoError(t, err) resultNames := make([]string, 0, len(result)) for _, c := range result { resultNames = append(resultNames, filepath.Base(c.Path())) } assert.ElementsMatch(t, tt.expected, resultNames, tt.description) }) } } func TestFilter_EdgeCasesAndErrorHandling(t *testing.T) { t.Parallel() t.Run("filter with no matches", func(t *testing.T) { t.Parallel() l := log.New() result, err := filter.Apply(l, "nonexistent", testComponents) require.NoError(t, err) assert.Empty(t, result) }) t.Run("multiple parse and evaluate calls", func(t *testing.T) { t.Parallel() filter, err := filter.Parse("app1") require.NoError(t, err) l := log.New() result1, err := filter.Evaluate(l, testComponents) require.NoError(t, err) result2, err := filter.Evaluate(l, testComponents) require.NoError(t, err) assert.Equal(t, result1, result2) }) t.Run("whitespace handling", func(t *testing.T) { t.Parallel() tests := []struct { filterString string }{ {"./apps/* | !legacy"}, {" ./apps/* | !legacy "}, {"./apps/* | !legacy"}, } expected := component.Components{ component.NewUnit("./apps/app1").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), component.NewUnit("./apps/app2").WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }), } for _, tt := range tests { l := log.New() result, err := filter.Apply(l, tt.filterString, testComponents) require.NoError(t, err) assert.ElementsMatch(t, expected, result) } }) } ================================================ FILE: internal/filter/filters.go ================================================ package filter import ( "encoding/json" "fmt" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Filters represents multiple filter queries that are evaluated with union (OR) semantics. // Multiple filters in Filters are always unioned (as opposed to multiple filters // within one filter string separated by |, which are intersected). type Filters []*Filter // ParseFilterQueries parses multiple filter strings and returns a Filters object. // Collects all parse errors and returns them as a joined error if any occur. // Returns an empty Filters if filterStrings is empty. // Color output for diagnostics is determined by the logger's color settings and terminal detection. func ParseFilterQueries(l log.Logger, filterStrings []string) (Filters, error) { if len(filterStrings) == 0 { return Filters{}, nil } // Determine if we should use color based on logger settings and terminal detection. // Error output goes to stderr, so we check if stderr is redirected. useColor := !l.Formatter().DisabledColors() filters := make([]*Filter, 0, len(filterStrings)) var diagnostics []string for i, filterString := range filterStrings { filter, err := Parse(filterString) if err != nil { var parseErr ParseError if errors.As(err, &parseErr) { diagnostics = append(diagnostics, FormatDiagnostic(&parseErr, i, useColor)) continue } diagnostics = append(diagnostics, fmt.Sprintf("filter %d: %v", i, err)) continue } filters = append(filters, filter) } result := Filters(filters) if len(diagnostics) > 0 { return result, fmt.Errorf("%s", strings.Join(diagnostics, "\n")) } return result, nil } // HasPositiveFilter returns true if the filters have any positive filters. func (f Filters) HasPositiveFilter() bool { for _, filter := range f { if !IsNegated(filter.expr) { return true } } return false } // RequiresDiscovery returns the first expression that requires discovery of Terragrunt components if any do. func (f Filters) RequiresDiscovery() (Expression, bool) { for _, filter := range f { if e, ok := filter.expr.RequiresDiscovery(); ok { return e, true } } return nil, false } // RequiresParse returns the first expression that requires parsing of Terragrunt HCL configurations if any do. func (f Filters) RequiresParse() (Expression, bool) { for _, filter := range f { if e, ok := filter.RequiresParse(); ok { return e, true } } return nil, false } // DependencyGraphExpressions returns all target expressions from graph expressions that require dependency traversal. func (f Filters) DependencyGraphExpressions() []Expression { targets := make([]Expression, 0, len(f)) for _, filter := range f { targets = append(targets, collectGraphExpressionTargetsWithDependencies(filter.expr)...) } return targets } // DependentGraphExpressions returns all target expressions from graph expressions that require dependent traversal. func (f Filters) DependentGraphExpressions() []Expression { targets := make([]Expression, 0, len(f)) for _, filter := range f { targets = append(targets, collectGraphExpressionTargetsWithDependents(filter.expr)...) } return targets } // UniqueGitFilters returns all unique Git filters that require worktree discovery. func (f Filters) UniqueGitFilters() GitExpressions { var targets GitExpressions seen := make(map[string]struct{}) for _, filter := range f { filterWorktreeExpressions := collectWorktreeExpressions(filter.expr) for _, filterWorktreeExpression := range filterWorktreeExpressions { if _, ok := seen[filterWorktreeExpression.String()]; ok { continue } seen[filterWorktreeExpression.String()] = struct{}{} targets = append(targets, filterWorktreeExpression) } } return targets } // RestrictToStacks returns a new Filters object with only the filters that are restricted to stacks. func (f Filters) RestrictToStacks() Filters { return slices.Collect(func(yield func(*Filter) bool) { for _, filter := range f { if filter.expr.IsRestrictedToStacks() && !yield(filter) { return } } }) } // collectGraphExpressionTargetsWithDependencies collects target expressions from GraphExpression nodes that have IncludeDependencies set. func collectGraphExpressionTargetsWithDependencies(expr Expression) []Expression { var targets []Expression WalkExpressions(expr, func(e Expression) bool { if graphExpr, ok := e.(*GraphExpression); ok && graphExpr.IncludeDependencies { targets = append(targets, graphExpr.Target) } return true }) return targets } // collectGraphExpressionTargetsWithDependents collects target expressions from GraphExpression nodes that have IncludeDependents set. func collectGraphExpressionTargetsWithDependents(expr Expression) []Expression { var targets []Expression WalkExpressions(expr, func(e Expression) bool { if graphExpr, ok := e.(*GraphExpression); ok && graphExpr.IncludeDependents { targets = append(targets, graphExpr.Target) } return true }) return targets } // collectWorktreeExpressions collects worktree expressions from GitExpression nodes. func collectWorktreeExpressions(expr Expression) []*GitExpression { var targets []*GitExpression WalkExpressions(expr, func(e Expression) bool { if gitExpr, ok := e.(*GitExpression); ok { targets = append(targets, gitExpr) } return true }) return targets } // collectGitReferences collects Git references from GitExpression nodes. func collectGitReferences(expr Expression) []string { var refs []string WalkExpressions(expr, func(e Expression) bool { if gitExpr, ok := e.(*GitExpression); ok { refs = append(refs, gitExpr.FromRef, gitExpr.ToRef) } return true }) return refs } // Evaluate applies all filters with union (OR) semantics in two phases: // 1. Positive filters (non-negated) are evaluated and their results are unioned // 2. Negative filters (starting with negation) are evaluated against the combined // results and remove matching components // // If logger is provided, it will be used for logging warnings during evaluation. func (f Filters) Evaluate(l log.Logger, components component.Components) (component.Components, error) { if len(f) == 0 { return components, nil } var ( positiveFilters = make([]*Filter, 0, len(f)) negativeFilters = make([]*Filter, 0, len(f)) ) for _, filter := range f { if IsNegated(filter.expr) { negativeFilters = append(negativeFilters, filter) continue } positiveFilters = append(positiveFilters, filter) } // Phase 1: Get initial set of components, which might need to be filtered further by negative filters combined, err := initialComponents(l, positiveFilters, components) if err != nil { return nil, err } if len(negativeFilters) == 0 { return combined, nil } // Phase 2: Apply negative filters to find components to remove toRemove := make(component.Components, 0, len(combined)) for _, filter := range negativeFilters { removed, err := filter.Negated().Evaluate(l, combined) if err != nil { return nil, err } for _, c := range removed { if !slices.Contains(toRemove, c) { toRemove = append(toRemove, c) } } } if len(toRemove) == 0 { return combined, nil } // Phase 3: Remove components from the initial set // We don't use slices.DeleteFunc here because we don't want the members of the original components slice to be // zeroed. results := make(component.Components, 0, len(combined)-len(toRemove)) for _, c := range combined { if slices.Contains(toRemove, c) { continue } results = append(results, c) } return results, nil } // EvaluateOnFiles evaluates the filters on a list of files and returns the filtered result. // This is useful for the hcl format command, where we want to evaluate filters on files // rather than directories, like we do with components. func (f Filters) EvaluateOnFiles(l log.Logger, files []string, workingDir string) (component.Components, error) { if e, ok := f.RequiresDiscovery(); ok { return nil, FilterQueryRequiresDiscoveryError{Query: e.String()} } comps := make(component.Components, 0, len(files)) for _, file := range files { unit := component.NewUnit(file) if workingDir != "" { unit = unit.WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: workingDir, }) } comps = append(comps, unit) } if len(f) == 0 { return comps, nil } return f.Evaluate(l, comps) } func initialComponents(l log.Logger, positiveFilters []*Filter, components component.Components) (component.Components, error) { if len(positiveFilters) == 0 { return components, nil } seen := make(map[string]component.Component, len(components)) for _, filter := range positiveFilters { result, err := filter.Evaluate(l, components) if err != nil { return nil, err } for _, c := range result { seen[c.Path()] = c } } remaining := make(component.Components, 0, len(seen)) for _, c := range seen { remaining = append(remaining, c) } return remaining, nil } // String returns a JSON array representation of all filter strings. func (f Filters) String() string { filterStrings := make([]string, len(f)) for i, filter := range f { filterStrings[i] = filter.String() } jsonBytes, err := json.Marshal(filterStrings) if err != nil { return "[]" } return string(jsonBytes) } ================================================ FILE: internal/filter/filters_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testLogger creates a logger for tests with colors disabled. func testLogger() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)) } func TestFilters_ParseFilterQueries(t *testing.T) { t.Parallel() t.Run("empty filter list", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{}) require.NoError(t, err) assert.NotNil(t, filters) assert.Equal(t, "[]", filters.String()) }) t.Run("single valid filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*"}) require.NoError(t, err) assert.NotNil(t, filters) assert.Equal(t, `["./apps/*"]`, filters.String()) }) t.Run("multiple valid filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=db", "!legacy"}) require.NoError(t, err) assert.NotNil(t, filters) assert.Equal(t, `["./apps/*","name=db","!legacy"]`, filters.String()) }) t.Run("single invalid filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"invalid |"}) require.Error(t, err) assert.NotNil(t, filters) // Rich diagnostic format assert.Contains(t, err.Error(), "--filter") assert.Contains(t, err.Error(), "invalid |") }) t.Run("mixed valid and invalid filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=", "!legacy"}) require.Error(t, err) assert.NotNil(t, filters) // Should have 2 valid filters parsed assert.Contains(t, filters.String(), "./apps/*") assert.Contains(t, filters.String(), "!legacy") // Error should mention the invalid filter with rich diagnostic format assert.Contains(t, err.Error(), "--filter[1]") assert.Contains(t, err.Error(), "name=") }) t.Run("multiple invalid filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"foo |", "bar |", "!baz"}) require.Error(t, err) assert.NotNil(t, filters) // Should have 1 valid filter assert.Equal(t, `["!baz"]`, filters.String()) // Error should mention both invalid filters with rich diagnostic format assert.Contains(t, err.Error(), "--filter 'foo |'") assert.Contains(t, err.Error(), "--filter[1]") }) t.Run("filter in parent directory", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"../apps/*"}) require.NoError(t, err) assert.NotNil(t, filters) assert.Equal(t, `["../apps/*"]`, filters.String()) }) t.Run("name filter with slash", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app/1"}) require.NoError(t, err) assert.NotNil(t, filters) assert.Equal(t, `["app/1"]`, filters.String()) }) } func TestFilters_Evaluate(t *testing.T) { t.Parallel() components := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), component.NewUnit("./libs/db"), component.NewUnit("./libs/api"), } for _, c := range components { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } t.Run("empty filters returns all components", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) assert.ElementsMatch(t, components, result) }) t.Run("single positive filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) t.Run("union of multiple positive filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/app1", "name=db"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./libs/db"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) t.Run("union with overlapping results (deduplication)", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=app1"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./apps/legacy"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) // Verify no duplicates - should have exactly 3 components assert.Len(t, result, 3) }) t.Run("positive filters then negative filter removes results", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "!legacy"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) t.Run("multiple negative filters applied in sequence", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "!legacy", "!app2"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) t.Run("only negative filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!legacy", "!db"}) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./libs/api"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) t.Run("complex mix of positive and negative filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{ "./apps/*", "./libs/*", "!legacy", "!api", }) require.NoError(t, err) l := log.New() result, err := filters.Evaluate(l, components) require.NoError(t, err) expected := component.Components{ component.NewUnit("./apps/app1"), component.NewUnit("./apps/app2"), component.NewUnit("./libs/db"), } for _, c := range expected { c.SetDiscoveryContext(&component.DiscoveryContext{ WorkingDir: ".", }) } assert.ElementsMatch(t, expected, result) }) } func TestFilters_HasPositiveFilter(t *testing.T) { t.Parallel() t.Run("empty filters - has positive filter is false", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{}) require.NoError(t, err) assert.False(t, filters.HasPositiveFilter()) }) t.Run("single positive filter - has positive filter is true", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*"}) require.NoError(t, err) assert.True(t, filters.HasPositiveFilter()) }) t.Run("single negative filter - has positive filter is false", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!legacy"}) require.NoError(t, err) assert.False(t, filters.HasPositiveFilter()) }) t.Run("multiple negative filters - has positive filter is false", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!legacy", "!test"}) require.NoError(t, err) assert.False(t, filters.HasPositiveFilter()) }) t.Run("multiple positive filters - has positive filter is true", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "./libs/*"}) require.NoError(t, err) assert.True(t, filters.HasPositiveFilter()) }) t.Run("mixed positive and negative - has positive filter is true", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "!legacy"}) require.NoError(t, err) assert.True(t, filters.HasPositiveFilter()) }) t.Run("negative then positive - has positive filter is true", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!legacy", "./apps/*"}) require.NoError(t, err) assert.True(t, filters.HasPositiveFilter()) }) } func TestFilters_String(t *testing.T) { t.Parallel() t.Run("empty filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{}) require.NoError(t, err) assert.Equal(t, "[]", filters.String()) }) t.Run("single filter", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*"}) require.NoError(t, err) assert.Equal(t, `["./apps/*"]`, filters.String()) }) t.Run("multiple filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=db", "!legacy"}) require.NoError(t, err) assert.Equal(t, `["./apps/*","name=db","!legacy"]`, filters.String()) }) } func TestFilters_RequiresDependencyDiscovery(t *testing.T) { t.Parallel() t.Run("no graph expressions - empty result", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=db"}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() assert.Empty(t, targets) }) t.Run("single dependency graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) // Verify the target is the correct expression assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) t.Run("multiple dependency graph expressions", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app...", "db..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 2) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) assert.Equal(t, mustAttr(t, "name", "db"), targets[1]) }) t.Run("dependent-only graph expression - no dependency discovery", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app"}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() assert.Empty(t, targets) }) t.Run("both directions graph expression - includes dependency discovery", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) t.Run("nested graph expressions in infix", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app... | db..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 2) }) t.Run("graph expression in prefix expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!app..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) }) t.Run("mixed graph and non-graph expressions", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app...", "./apps/*"}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) } func TestFilters_RequiresDependentDiscovery(t *testing.T) { t.Parallel() t.Run("no graph expressions - empty result", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=db"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() assert.Empty(t, targets) }) t.Run("single dependent graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 1) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) t.Run("multiple dependent graph expressions", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app", "...db"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 2) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) assert.Equal(t, mustAttr(t, "name", "db"), targets[1]) }) t.Run("dependency-only graph expression - no dependent discovery", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"app..."}) require.NoError(t, err) targets := filters.DependentGraphExpressions() assert.Empty(t, targets) }) t.Run("both directions graph expression - includes dependent discovery", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app..."}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 1) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) t.Run("nested graph expressions in infix", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app | ...db"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 2) }) t.Run("graph expression in prefix expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"!...app"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 1) }) t.Run("mixed graph and non-graph expressions", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...app", "./apps/*"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 1) assert.Equal(t, mustAttr(t, "name", "app"), targets[0]) }) } func TestFilters_RestrictToStacks(t *testing.T) { t.Parallel() t.Run("empty filters - empty result", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{}) require.NoError(t, err) restricted := filters.RestrictToStacks() assert.Empty(t, restricted) }) t.Run("single filter - restricted to stacks", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"type=stack"}) require.NoError(t, err) restricted := filters.RestrictToStacks() require.Len(t, restricted, 1) }) t.Run("multiple filters - one of them restricted to stacks", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"type=stack", "name=app"}) require.NoError(t, err) restricted := filters.RestrictToStacks() require.Len(t, restricted, 1) }) t.Run("multiple filters - none of them restricted to stacks", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"name=app", "type=unit"}) require.NoError(t, err) restricted := filters.RestrictToStacks() require.Empty(t, restricted) }) } func TestFilters_RequiresGitReferences(t *testing.T) { t.Parallel() t.Run("no Git filters - empty result", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"./apps/*", "name=db"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() assert.Empty(t, refs) }) t.Run("single Git filter with one reference", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main]"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("single Git filter with two references", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("multiple Git filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]", "[feature-branch]"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 3) assert.ElementsMatch(t, refs, []string{"main", "HEAD", "feature-branch"}) }) t.Run("Git filters with deduplication", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]", "[HEAD...main]"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) // main and HEAD, no duplicates assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("Git filter combined with other filters", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]", "./apps/*", "name=db"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("Git filter with negation", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"![main...HEAD]"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("Git filter with intersection", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD] | ./apps/*"}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) t.Run("Git filter nested in graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]..."}) require.NoError(t, err) refs := filters.UniqueGitFilters().UniqueGitRefs() require.Len(t, refs, 2) assert.ElementsMatch(t, refs, []string{"main", "HEAD"}) }) } // TestFilters_GitExpressionAsGraphTarget tests that Filters correctly extracts // GitExpression targets from GraphExpressions for dependency/dependent discovery. func TestFilters_GitExpressionAsGraphTarget(t *testing.T) { t.Parallel() t.Run("DependencyGraphExpressions extracts GitExpression target - dependencies only", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main...HEAD]..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1, "Should have one dependency target expression") gitExpr, ok := targets[0].(*filter.GitExpression) require.True(t, ok, "Target should be a GitExpression, got %T", targets[0]) assert.Equal(t, "main", gitExpr.FromRef) assert.Equal(t, "HEAD", gitExpr.ToRef) }) t.Run("DependentGraphExpressions extracts GitExpression target - dependents only", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[main...HEAD]"}) require.NoError(t, err) targets := filters.DependentGraphExpressions() require.Len(t, targets, 1, "Should have one dependent target expression") gitExpr, ok := targets[0].(*filter.GitExpression) require.True(t, ok, "Target should be a GitExpression, got %T", targets[0]) assert.Equal(t, "main", gitExpr.FromRef) assert.Equal(t, "HEAD", gitExpr.ToRef) }) t.Run("Both graph expressions extract GitExpression target - issue #5307 pattern", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[main...HEAD]..."}) require.NoError(t, err) depTargets := filters.DependencyGraphExpressions() require.Len(t, depTargets, 1, "Should have one dependency target expression") depGitExpr, ok := depTargets[0].(*filter.GitExpression) require.True(t, ok, "Dependency target should be a GitExpression") assert.Equal(t, "main", depGitExpr.FromRef) assert.Equal(t, "HEAD", depGitExpr.ToRef) depentTargets := filters.DependentGraphExpressions() require.Len(t, depentTargets, 1, "Should have one dependent target expression") depentGitExpr, ok := depentTargets[0].(*filter.GitExpression) require.True(t, ok, "Dependent target should be a GitExpression") assert.Equal(t, "main", depentGitExpr.FromRef) assert.Equal(t, "HEAD", depentGitExpr.ToRef) }) t.Run("UniqueGitFilters extracts GitExpression from all graph positions", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[main...HEAD]..."}) require.NoError(t, err) gitFilters := filters.UniqueGitFilters() require.Len(t, gitFilters, 1, "Should have one unique git filter") assert.Equal(t, "main", gitFilters[0].FromRef) assert.Equal(t, "HEAD", gitFilters[0].ToRef) }) t.Run("Multiple git expressions in graph - unique extraction", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{ "[main...HEAD]...", "...[feature...develop]", }) require.NoError(t, err) depTargets := filters.DependencyGraphExpressions() require.Len(t, depTargets, 1, "First filter has dependencies") depentTargets := filters.DependentGraphExpressions() require.Len(t, depentTargets, 1, "Second filter has dependents") gitFilters := filters.UniqueGitFilters() require.Len(t, gitFilters, 2, "Should have two unique git filters") }) t.Run("Exclude target with GitExpression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"^[main...HEAD]..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) gitExpr, ok := targets[0].(*filter.GitExpression) require.True(t, ok) assert.Equal(t, "main", gitExpr.FromRef) }) t.Run("Single git ref with graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"[main]..."}) require.NoError(t, err) targets := filters.DependencyGraphExpressions() require.Len(t, targets, 1) gitExpr, ok := targets[0].(*filter.GitExpression) require.True(t, ok) assert.Equal(t, "main", gitExpr.FromRef) assert.Equal(t, "HEAD", gitExpr.ToRef) }) t.Run("Git expression with commit SHA in graph", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[abc123...def456]..."}) require.NoError(t, err) depTargets := filters.DependencyGraphExpressions() require.Len(t, depTargets, 1) gitExpr, ok := depTargets[0].(*filter.GitExpression) require.True(t, ok) assert.Equal(t, "abc123", gitExpr.FromRef) assert.Equal(t, "def456", gitExpr.ToRef) }) t.Run("RequiresParse returns true for git-graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[main...HEAD]..."}) require.NoError(t, err) expr, requires := filters.RequiresParse() assert.True(t, requires, "Git-graph expression should require parsing") assert.NotNil(t, expr) }) t.Run("HasPositiveFilter returns true for git-graph expression", func(t *testing.T) { t.Parallel() filters, err := filter.ParseFilterQueries(testLogger(), []string{"...[main...HEAD]..."}) require.NoError(t, err) assert.True(t, filters.HasPositiveFilter(), "Git-graph expression is a positive filter") }) } ================================================ FILE: internal/filter/fuzz_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" ) // FuzzParse tests the main Parse() function with arbitrary input. // It verifies that Parse never panics regardless of input. func FuzzParse(f *testing.F) { seeds := []string{ // Simple paths "foo", "foo/bar", "**/*.hcl", "./apps", "./apps/*", "./apps/**/foo", "/absolute/path", "../foo", "./my-app_v2/foo-bar", // Attributes "name=foo", "name=bar", "type=unit", "key=value", "source=github.com/acme/foo/bar", // Operators "!foo", "foo | bar", "!name=bar", "!./apps/legacy", "./apps/* | name=bar", "name=foo | !./legacy | ./apps/**", // Graph expressions "...foo", "foo...", "...foo...", "^foo", "...^foo", "^foo...", "...^foo...", "1...foo", "foo...1", "2...foo...3", "1...^foo...2", // Braced paths "{./apps/*}", "{my path/file}", "!{./apps/legacy}", "{1}", // Git refs "[HEAD]", "[main]", "[main...HEAD]", "[main...feature]", "[abc123...def456]", "[v1.0.0...v2.0.0]", "[HEAD~1...HEAD]", "[feature/name]", // Git + graph combinations "[main...HEAD]...", "...[main...HEAD]", "...[main...HEAD]...", "...^[main...HEAD]...", // Edge cases "", ".", "...", "{", "[", "!", "|", "=", "^", "}", "]", "[]", "{}", ".gitignore", ".terragrunt-cache", "@username", "foo bar", " \t\n ", "..1", "..25", "foo..bar", "foo..1", "..2foo", } for _, seed := range seeds { f.Add(seed) } f.Fuzz(func(t *testing.T, input string) { _, _ = filter.Parse(input) }) } // FuzzLexer tests the lexer by tokenizing arbitrary input. // It verifies that the lexer never panics and always terminates. func FuzzLexer(f *testing.F) { seeds := []string{ // Single operators "!", "|", "=", "{", "}", "[", "]", "^", "...", // Identifiers "foo", "foo_bar", "foo-bar", ".gitignore", ".terragrunt-cache", "@username", // Paths "./apps", "/absolute/path", "./apps/*", "./apps/**/foo", "../foo", "foo/bar", // Complex sequences "name=foo", "!name=bar", "./apps/* | name=bar", "name=foo | !./legacy | ./apps/**", "...foo", "foo...", "1...foo", "foo...1", "{./apps/*}", "[main...HEAD]", // Edge cases "", " \t\n ", ".", "..1", "..25", "foo..bar", "1...1", "99999999999999999999999...foo", } for _, seed := range seeds { f.Add(seed) } f.Fuzz(func(t *testing.T, input string) { lexer := filter.NewLexer(input) // Tokenize until EOF - should never hang or panic // Set a reasonable limit to prevent infinite loops in case of bugs const maxTokens = 10000 for range maxTokens { tok := lexer.NextToken() if tok.Type == filter.EOF { break } } }) } // FuzzParser tests the parser by parsing arbitrary input. // It verifies that the parser never panics during AST construction. func FuzzParser(f *testing.F) { seeds := []string{ // Simple expressions "foo", "name=bar", "./apps/*", "{./apps/*}", "[main]", // Prefix expressions "!foo", "!name=bar", "!./apps/legacy", "!{./apps/legacy}", "![main...HEAD]", // Infix expressions "foo | bar", "foo | bar | baz", "./apps/* | name=bar", "name=foo | !./legacy | ./apps/**", "!foo | bar", "foo | !bar", // Graph expressions "...foo", "foo...", "...foo...", "^foo", "...^foo", "^foo...", "...^foo...", "1...foo", "foo...1", "2...foo...3", "1...^foo...2", "10...foo...25", "999999999...foo", // Git expressions "[main]", "[main...HEAD]", "[abc123...def456]", "[v1.0.0...v2.0.0]", "[HEAD~1...HEAD]", "[feature/name]", // Combined expressions "[main...HEAD]...", "...[main...HEAD]", "...[main...HEAD]...", "[main...HEAD] | ./apps/*", "!...foo", "...!foo", "...foo | bar", "foo | bar...", // Error cases (parser should handle gracefully) "", "!", "name=", "foo |", "foo | bar |", "|", "| foo", "[]", "[main", "[...]", "[main...]", "]", "{}", "{", "...", "^", "... |", "^ |", "1...", "1... ", "1......2", } for _, seed := range seeds { f.Add(seed) } f.Fuzz(func(t *testing.T, input string) { lexer := filter.NewLexer(input) parser := filter.NewParser(lexer) // ParseExpression should not panic on any input // It may return an error for invalid input, which is expected _, _ = parser.ParseExpression() }) } ================================================ FILE: internal/filter/hints.go ================================================ package filter import ( "fmt" "strings" ) // GetHint returns a single consolidated hint for a parse error. func GetHint(code ErrorCode, token, query string, position int) string { switch code { case ErrorCodeUnexpectedToken: return getUnexpectedTokenHint(token, query, position) case ErrorCodeMissingClosingBracket: return getMissingClosingBracketHint(query) case ErrorCodeMissingClosingBrace: return getMissingClosingBraceHint(query) case ErrorCodeMissingGitRef: return "Git filters with '...' require a reference on each side. e.g. '[main...HEAD]'" case ErrorCodeUnexpectedEOF: return getUnexpectedEOFHint(query) case ErrorCodeIllegalToken: return "This character is not recognized. Valid operators: | (union), ! (negation), = (attribute)" // These have error messages that are pretty self-explanatory and don't need hints. case ErrorCodeEmptyGitFilter, ErrorCodeEmptyExpression, ErrorCodeMissingOperand, ErrorCodeInvalidGlob: return "" // These are errors that don't have obvious hints that can be offered. case ErrorCodeUnknown: return "" } return "" } // getUnexpectedTokenHint returns a single hint specific to unexpected token errors. func getUnexpectedTokenHint(token, query string, position int) string { switch token { case "^": return getCaretHint(query, position) case "|": return "" case "=": return "The equals sign is used for attribute filters. e.g. 'name=foo'" case "]": return "Unexpected ']' without matching '['. Git-based expressions use square brackets. e.g. '[main...HEAD]'" case "}": return "Unexpected '}' without matching '{'. Explicit path expressions use braces. e.g. '{./my path}'" case "...": return "The '...' operator must be used in either a graph-based or Git-based expression. e.g. '...foo...' or '[main...HEAD]'" } // Generic unexpected token hints if strings.HasPrefix(token, ".") || strings.HasPrefix(token, "/") { return "Path expressions should start with './' for relative or '/' for absolute paths." } return "" } // getCaretHint returns a single hint for caret (^) token errors. func getCaretHint(query string, position int) string { // Check if caret is at start - suggests graph exclusion usage if position == 0 { return "The '^' operator excludes the target from graph results. e.g. '^foo...' selects foo's dependents but not foo itself." } // Check if caret follows text (e.g., "HEAD^") if position > 0 { beforeCaret := strings.TrimSpace(query[:position]) // Check if it follows an ellipsis - suggest moving caret to left side if targetPart, found := strings.CutSuffix(beforeCaret, "..."); found { // Extract the target before the ellipsis for a dynamic suggestion return fmt.Sprintf("The '^' operator excludes the target from graph results when used on the left side of the expression. Did you mean '^%s...'?", targetPart) } // Find the immediate identifier before caret (split by operators/whitespace) parts := strings.FieldsFunc(beforeCaret, func(r rune) bool { return r == ' ' || r == '\t' || r == '|' || r == '!' || r == '=' || r == '{' || r == '}' || r == '[' || r == ']' }) if len(parts) > 0 { lastIdent := parts[len(parts)-1] if lastIdent != "" { return fmt.Sprintf("Git-based expressions require surrounding references with '[]'. Did you mean '[%s^]'?", lastIdent) } } } // Caret at start or in unusual position return "The '^' operator must be used in either a graph-based or Git-based expression. e.g. '...^foo...' or '[HEAD^]'" } // getUnexpectedEOFHint returns a context-aware hint for unexpected end of input. func getUnexpectedEOFHint(query string) string { trimmed := strings.TrimSpace(query) if strings.HasSuffix(trimmed, "...") { return "The '...' operator must be used in either a graph-based or Git-based expression. e.g. '...foo...' or '[main...HEAD]'" } if strings.HasSuffix(trimmed, "^") { return "The '^' operator must be used in either a graph-based or Git-based expression. e.g. '...^foo...' or '[HEAD^]'" } return "The expression is incomplete. Make sure all brackets are closed and operators have operands." } // getMissingClosingBracketHint returns a dynamic hint for unclosed Git filter expressions. func getMissingClosingBracketHint(query string) string { if _, content, found := strings.Cut(query, "["); found { return fmt.Sprintf("Git-based expressions require surrounding references with '[]'. Did you mean '[%s]'?", content) } return "Git-based expressions require surrounding references with '[]'. e.g. '[main...HEAD]'" } // getMissingClosingBraceHint returns a dynamic hint for unclosed braced path expressions. func getMissingClosingBraceHint(query string) string { if _, content, found := strings.Cut(query, "{"); found { return fmt.Sprintf("Explicit path expressions require surrounding paths with '{}'. Did you mean '{%s}'?", content) } return "Explicit path expressions require surrounding paths with '{}'. e.g. '{path/with spaces}'" } ================================================ FILE: internal/filter/hints_test.go ================================================ package filter_test import ( "regexp" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestHints_Golden tests the full rendered error messages for golden/regression testing. func TestHints_Golden(t *testing.T) { t.Parallel() testCases := []struct { name string query string expected string }{ { name: "git syntax caret after ref", query: "HEAD^", expected: `Filter parsing error: Unexpected token --> --filter 'HEAD^' HEAD^ ^ Unexpected '^' after expression hint: Git-based expressions require surrounding references with '[]'. Did you mean '[HEAD^]'? `, }, { name: "unclosed bracket", query: "[main...HEAD", expected: `Filter parsing error: Unclosed Git filter expression --> --filter '[main...HEAD' [main...HEAD ^ This Git-based expression is missing a closing ']' hint: Git-based expressions require surrounding references with '[]'. Did you mean '[main...HEAD]'? `, }, { name: "unclosed brace", query: "{my path", expected: `Filter parsing error: Unclosed path expression --> --filter '{my path' {my path ^ This braced path expression is missing a closing '}' hint: Explicit path expressions require surrounding paths with '{}'. Did you mean '{my path}'? `, }, { name: "empty git filter", query: "[]", expected: `Filter parsing error: Empty Git filter --> --filter '[]' [] ^ Git filter expression cannot be empty `, }, { name: "pipe at start", query: "| foo", expected: `Filter parsing error: Unexpected token --> --filter '| foo' | foo ^ Missing left-hand side of '|' operator `, }, { name: "pipe at end", query: "foo |", expected: `Filter parsing error: Unexpected end of input --> --filter 'foo |' foo | ^ Missing right-hand side of '|' operator `, }, { name: "bang without operand", query: "!", expected: `Filter parsing error: Unexpected end of input --> --filter '!' ! ^ Missing target expression for '!' operator `, }, { name: "Unexpected closing bracket", query: "]", expected: `Filter parsing error: Unexpected token --> --filter ']' ] ^ Unexpected ']' hint: Unexpected ']' without matching '['. Git-based expressions use square brackets. e.g. '[main...HEAD]' `, }, { name: "Unexpected closing brace", query: "}", expected: `Filter parsing error: Unexpected token --> --filter '}' } ^ Unexpected '}' hint: Unexpected '}' without matching '{'. Explicit path expressions use braces. e.g. '{./my path}' `, }, { name: "equals without context", query: "=foo", expected: `Filter parsing error: Unexpected token --> --filter '=foo' =foo ^ Unexpected '=' hint: The equals sign is used for attribute filters. e.g. 'name=foo' `, }, { name: "caret at start", query: "^", expected: `Filter parsing error: Unexpected end of input --> --filter '^' ^ ^ Expression is incomplete hint: The '^' operator must be used in either a graph-based or Git-based expression. e.g. '...^foo...' or '[HEAD^]' `, }, { name: "ellipsis at start", query: "...", expected: `Filter parsing error: Unexpected end of input --> --filter '...' ... ^ Expression is incomplete hint: The '...' operator must be used in either a graph-based or Git-based expression. e.g. '...foo...' or '[main...HEAD]' `, }, // TODO: Make this not an error. This should just be a path expression pointing at the current directory. { name: "illegal character", query: ".", expected: `Filter parsing error: Illegal token --> --filter '.' . ^ Unrecognized character '.' hint: This character is not recognized. Valid operators: | (union), ! (negation), = (attribute) `, }, { name: "missing git ref after ellipsis", query: "[main...]", expected: `Filter parsing error: Missing Git reference --> --filter '[main...]' [main...] ^ Expected second Git reference after '...' hint: Git filters with '...' require a reference on each side. e.g. '[main...HEAD]' `, }, { name: "complex expression with caret", query: "./apps/* | HEAD^", expected: `Filter parsing error: Unexpected token --> --filter './apps/* | HEAD^' ./apps/* | HEAD^ ^ Unexpected '^' after expression hint: Git-based expressions require surrounding references with '[]'. Did you mean '[HEAD^]'? `, }, { name: "caret after ellipsis", query: "./foo...^", expected: `Filter parsing error: Unexpected token --> --filter './foo...^' ./foo...^ ^ Unexpected '^' after expression hint: The '^' operator excludes the target from graph results when used on the left side of the expression. Did you mean '^./foo...'? `, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() output, err := renderParseError(tc.query) require.NoError(t, err) output = stripTimestampPrefix(output) assert.Equal(t, tc.expected, output) }) } } // TestHints_ErrorCodeCoverage verifies that all error codes produce appropriate hints. func TestHints_ErrorCodeCoverage(t *testing.T) { t.Parallel() testCases := []struct { name string token string query string hintSubstring string code filter.ErrorCode position int expectHint bool }{ { name: "UnexpectedToken with pipe", code: filter.ErrorCodeUnexpectedToken, token: "|", query: "| foo", position: 0, expectHint: false, }, { name: "UnexpectedToken with caret", code: filter.ErrorCodeUnexpectedToken, token: "^", query: "HEAD^", position: 4, expectHint: true, hintSubstring: "Git", }, { name: "UnexpectedToken with equals", code: filter.ErrorCodeUnexpectedToken, token: "=", query: "=foo", position: 0, expectHint: true, hintSubstring: "attribute", }, { name: "UnexpectedToken with closing bracket", code: filter.ErrorCodeUnexpectedToken, token: "]", query: "]", position: 0, expectHint: true, hintSubstring: "without matching '['", }, { name: "UnexpectedToken with closing brace", code: filter.ErrorCodeUnexpectedToken, token: "}", query: "}", position: 0, expectHint: true, hintSubstring: "without matching '{'", }, { name: "UnexpectedToken with ellipsis", code: filter.ErrorCodeUnexpectedToken, token: "...", query: "...", position: 0, expectHint: true, hintSubstring: "graph-based", }, { name: "MissingClosingBracket", code: filter.ErrorCodeMissingClosingBracket, token: "", query: "[main", position: 5, expectHint: true, hintSubstring: "require surrounding references with '[]'", }, { name: "MissingClosingBrace", code: filter.ErrorCodeMissingClosingBrace, token: "", query: "{path", position: 5, expectHint: true, hintSubstring: "require surrounding paths with '{}'", }, { name: "MissingGitRef", code: filter.ErrorCodeMissingGitRef, token: "", query: "[main...]", position: 8, expectHint: true, hintSubstring: "require a reference on each side", }, { name: "MissingOperand", code: filter.ErrorCodeMissingOperand, token: "", query: "foo |", position: 5, expectHint: false, }, { name: "UnexpectedEOF", code: filter.ErrorCodeUnexpectedEOF, token: "", query: "...", position: 3, expectHint: true, hintSubstring: "expression", }, { name: "IllegalToken", code: filter.ErrorCodeIllegalToken, token: "@", query: "@", position: 0, expectHint: true, hintSubstring: "not recognized", }, { name: "EmptyGitFilter - no hint", code: filter.ErrorCodeEmptyGitFilter, token: "]", query: "[]", position: 1, expectHint: false, }, { name: "EmptyExpression - no hint", code: filter.ErrorCodeEmptyExpression, token: "}", query: "{}", position: 1, expectHint: false, }, { name: "Unknown - no hint", code: filter.ErrorCodeUnknown, token: "", query: "", position: 0, expectHint: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() hint := filter.GetHint(tc.code, tc.token, tc.query, tc.position) if tc.expectHint { require.NotEmpty( t, hint, "expected hint for error code %v", tc.code, ) assert.Contains( t, hint, tc.hintSubstring, "hint should contain '%s', got: %s", tc.hintSubstring, hint, ) return } assert.Empty(t, hint, "expected no hint for error code %v", tc.code) }) } } // TestHints_CaretContextualHints tests that caret hints vary based on context. func TestHints_CaretContextualHints(t *testing.T) { t.Parallel() testCases := []struct { name string query string hintSubstring string position int }{ { name: "caret after identifier suggests Git syntax", query: "HEAD^", position: 4, hintSubstring: "[HEAD^]", }, { name: "caret after ellipsis suggests graph exclusion", query: "foo...^bar", position: 6, hintSubstring: "excludes the target", }, { name: "caret at start suggests graph exclusion", query: "^foo", position: 0, hintSubstring: "excludes the target", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() hint := filter.GetHint(filter.ErrorCodeUnexpectedToken, "^", tc.query, tc.position) require.NotEmpty(t, hint) assert.Contains(t, hint, tc.hintSubstring) }) } } // TestHints_FormatDiagnosticStructure verifies the overall structure of diagnostic output. func TestHints_FormatDiagnosticStructure(t *testing.T) { t.Parallel() parseErr := &filter.ParseError{ Title: "Test Error", Message: "test message", Position: 5, ErrorPosition: 5, Query: "test query", TokenLiteral: "q", TokenLength: 1, ErrorCode: filter.ErrorCodeMissingClosingBracket, } output := filter.FormatDiagnostic(parseErr, 0, false) lines := strings.Split(output, "\n") require.GreaterOrEqual(t, len(lines), 6, "diagnostic should have at least 6 lines") assert.Contains(t, lines[0], "Filter parsing error:") assert.Contains(t, lines[0], "Test Error") assert.Contains(t, lines[1], " --> ") assert.Contains(t, lines[1], "--filter") assert.Empty(t, lines[2]) assert.Contains(t, lines[3], "test query") assert.Contains(t, lines[4], "^") assert.Contains(t, lines[4], "test message") assert.Empty(t, lines[5]) assert.Contains(t, lines[6], "hint:") } // TestHints_FilterIndexInDiagnostic verifies filter index appears in multi-filter scenarios. func TestHints_FilterIndexInDiagnostic(t *testing.T) { t.Parallel() parseErr := &filter.ParseError{ Title: "Test Error", Message: "test message", Position: 0, ErrorPosition: 0, Query: "bad", TokenLiteral: "b", TokenLength: 1, ErrorCode: filter.ErrorCodeUnexpectedToken, } output0 := filter.FormatDiagnostic(parseErr, 0, false) assert.Contains(t, output0, "--filter 'bad'") assert.NotContains(t, output0, "--filter[") output2 := filter.FormatDiagnostic(parseErr, 2, false) assert.Contains(t, output2, "--filter[2]") } // stripTimestampPrefix removes any timestamp prefix from log output. // // Timestamps typically appear at the start of lines in formats like: // "2024-01-15T10:30:00Z" or "2024/01/15 10:30:00" // // This makes it easier to assert expected output in golden tests. func stripTimestampPrefix(s string) string { timestampPattern := regexp.MustCompile(`(?m)^(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}[^\s]*\s+)`) return timestampPattern.ReplaceAllString(s, "") } // renderParseError parses a filter query and returns the formatted diagnostic. // Returns an error if parsing succeeds (no error to render). func renderParseError(query string) (string, error) { _, err := filter.Parse(query) if err == nil { return "", errors.New("expected parse error but got none") } var parseErr filter.ParseError if !errors.As(err, &parseErr) { return "", errors.Errorf("expected ParseError but got: %v", err) } // Render without colors for consistent golden testing return filter.FormatDiagnostic(&parseErr, 0, false), nil } ================================================ FILE: internal/filter/lexer.go ================================================ package filter import ( "strings" "unicode" ) // Lexer tokenizes a filter query string. type Lexer struct { input string // The input string being tokenized position int // Current position in input (points to current char) readPosition int // Current reading position in input (after current char) ch byte // Current char under examination afterEqual bool // True if the last token was EQUAL (for parsing attribute values) } // NewLexer creates a new Lexer for the given input string. func NewLexer(input string) *Lexer { l := &Lexer{input: input} l.readChar() // Initialize by reading the first character return l } // Input returns the original input string. func (l *Lexer) Input() string { return l.input } // NextToken reads and returns the next token from the input. func (l *Lexer) NextToken() Token { l.skipWhitespace() var tok Token startPosition := l.position switch l.ch { case '!': tok = NewToken(BANG, string(l.ch), startPosition) l.readChar() case '|': tok = NewToken(PIPE, string(l.ch), startPosition) l.readChar() case '=': tok = NewToken(EQUAL, string(l.ch), startPosition) l.readChar() l.afterEqual = true return tok case '{': tok = NewToken(LBRACE, string(l.ch), startPosition) l.readChar() case '}': tok = NewToken(RBRACE, string(l.ch), startPosition) l.readChar() case '[': tok = NewToken(LBRACKET, string(l.ch), startPosition) l.readChar() case ']': tok = NewToken(RBRACKET, string(l.ch), startPosition) l.readChar() case '^': tok = NewToken(CARET, string(l.ch), startPosition) l.readChar() case 0: tok = NewToken(EOF, "", startPosition) case '.': if l.peekChar() == '.' { // Check for ellipsis (...) if l.readPosition+1 < len(l.input) && l.input[l.readPosition+1] == '.' { l.readChar() l.readChar() tok = NewToken(ELLIPSIS, "...", startPosition) l.readChar() return tok } // Check if this is .. followed by / (parent directory path) if l.readPosition+1 < len(l.input) && l.input[l.readPosition+1] == '/' { tok = l.readPath(startPosition) return tok } } switch nextCh := l.peekChar(); { case nextCh == '/': tok = l.readPath(startPosition) case isIdentifierChar(nextCh): literal := l.readIdentifier() tok = NewToken(IDENT, literal, startPosition) return tok default: tok = NewToken(ILLEGAL, string(l.ch), startPosition) l.readChar() } case '/': tok = l.readPath(startPosition) default: if l.afterEqual { // After '=', read as attribute value (can contain slashes) literal := l.readAttributeValue() tok = NewToken(IDENT, literal, startPosition) l.afterEqual = false return tok } if isIdentifierChar(l.ch) { // Check if this identifier contains a slash - if so, treat it as a path if l.containsSlashBeforeSpecialChar() { tok = l.readPath(startPosition) return tok } literal := l.readIdentifier() tok = NewToken(IDENT, literal, startPosition) return tok } tok = NewToken(ILLEGAL, string(l.ch), startPosition) l.readChar() } l.afterEqual = false return tok } // readChar advances the lexer's position and updates the current character. func (l *Lexer) readChar() { if l.readPosition >= len(l.input) { l.ch = 0 // ASCII code for "NUL", signifies end of input l.position = l.readPosition l.readPosition++ return } l.ch = l.input[l.readPosition] l.position = l.readPosition l.readPosition++ } // peekChar returns the next character without advancing the position. func (l *Lexer) peekChar() byte { if l.readPosition >= len(l.input) { return 0 } return l.input[l.readPosition] } // skipWhitespace skips over whitespace characters. func (l *Lexer) skipWhitespace() { for l.ch != 0 && unicode.IsSpace(rune(l.ch)) { l.readChar() } } // readIdentifier reads an identifier from the input. // Identifiers can contain letters, numbers, underscores, hyphens, dots, and other non-special chars. // This includes hidden files starting with a dot like .gitignore // Trailing whitespace is trimmed. func (l *Lexer) readIdentifier() string { position := l.position for isIdentifierChar(l.ch) { // stop at ellipsis (...) if l.ch == '.' && l.peekChar() == '.' { if l.readPosition+1 < len(l.input) && l.input[l.readPosition+1] == '.' { break } } l.readChar() } literal := l.input[position:l.position] return strings.TrimSpace(literal) } // readAttributeValue reads an attribute value from the input. // Attribute values can contain slashes, letters, numbers, underscores, hyphens, dots, etc. // They stop at special operators (|, !, {, }) or end of input. // Trailing whitespace is trimmed. func (l *Lexer) readAttributeValue() string { position := l.position for isAttributeValueChar(l.ch) { // stop at ellipsis (...) if l.ch == '.' && l.peekChar() == '.' { if l.readPosition+1 < len(l.input) && l.input[l.readPosition+1] == '.' { break } } l.readChar() } literal := l.input[position:l.position] return strings.TrimSpace(literal) } // readPath reads a path from the input. // Paths can contain any characters except special operators. // Trailing whitespace is trimmed. func (l *Lexer) readPath(startPosition int) Token { position := l.position for isPathChar(l.ch) { // stop at ellipsis (...) if l.ch == '.' && l.peekChar() == '.' { if l.readPosition+1 < len(l.input) && l.input[l.readPosition+1] == '.' { break } } l.readChar() } literal := l.input[position:l.position] literal = strings.TrimSpace(literal) return NewToken(PATH, literal, startPosition) } // containsSlashBeforeSpecialChar checks if there's a slash in the input before // we encounter a special character, starting from the current position. func (l *Lexer) containsSlashBeforeSpecialChar() bool { pos := l.position for pos < len(l.input) { ch := l.input[pos] if ch == '/' { return true } if isSpecialChar(ch) { return false } pos++ } return false } // isSpecialChar returns true if the character is a special operator or delimiter. func isSpecialChar(ch byte) bool { return ch == '!' || ch == '|' || ch == '=' || ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == '^' || ch == 0 } // isPathSeparator returns true if the character is a path separator. func isPathSeparator(ch byte) bool { return ch == '/' } // isIdentifierChar returns true if the character can be part of an identifier. func isIdentifierChar(ch byte) bool { return !isSpecialChar(ch) && !isPathSeparator(ch) } // isAttributeValueChar returns true if the character can be part of an attribute value. // Attribute values can contain slashes (unlike regular identifiers). func isAttributeValueChar(ch byte) bool { return !isSpecialChar(ch) } // isPathChar returns true if the character can be part of a path. func isPathChar(ch byte) bool { return !isSpecialChar(ch) } ================================================ FILE: internal/filter/lexer_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" ) func TestLexer_SingleTokens(t *testing.T) { t.Parallel() tests := []struct { name string input string expected []filter.Token }{ { name: "bang operator", input: "!", expected: []filter.Token{ {Type: filter.BANG, Literal: "!", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "pipe operator", input: "|", expected: []filter.Token{ {Type: filter.PIPE, Literal: "|", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "left brace", input: "{", expected: []filter.Token{ {Type: filter.LBRACE, Literal: "{", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "right brace", input: "}", expected: []filter.Token{ {Type: filter.RBRACE, Literal: "}", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "equal operator", input: "=", expected: []filter.Token{ {Type: filter.EQUAL, Literal: "=", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "simple identifier", input: "foo", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo", Position: 0}, {Type: filter.EOF, Literal: "", Position: 3}, }, }, { name: "identifier with underscore", input: "foo_bar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo_bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "identifier with hyphen", input: "foo-bar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo-bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "hidden file", input: ".gitignore", expected: []filter.Token{ {Type: filter.IDENT, Literal: ".gitignore", Position: 0}, {Type: filter.EOF, Literal: "", Position: 10}, }, }, { name: "hidden file with underscore", input: ".terragrunt-cache", expected: []filter.Token{ {Type: filter.IDENT, Literal: ".terragrunt-cache", Position: 0}, {Type: filter.EOF, Literal: "", Position: 17}, }, }, { name: "relative path", input: "./apps", expected: []filter.Token{ {Type: filter.PATH, Literal: "./apps", Position: 0}, {Type: filter.EOF, Literal: "", Position: 6}, }, }, { name: "absolute path", input: "/absolute/path", expected: []filter.Token{ {Type: filter.PATH, Literal: "/absolute/path", Position: 0}, {Type: filter.EOF, Literal: "", Position: 14}, }, }, { name: "glob path with single wildcard", input: "./apps/*", expected: []filter.Token{ {Type: filter.PATH, Literal: "./apps/*", Position: 0}, {Type: filter.EOF, Literal: "", Position: 8}, }, }, { name: "glob path with recursive wildcard", input: "./apps/**/foo", expected: []filter.Token{ {Type: filter.PATH, Literal: "./apps/**/foo", Position: 0}, {Type: filter.EOF, Literal: "", Position: 13}, }, }, { name: "ellipsis", input: "...", expected: []filter.Token{ {Type: filter.ELLIPSIS, Literal: "...", Position: 0}, {Type: filter.EOF, Literal: "", Position: 3}, }, }, { name: "double dots with digit is identifier", input: "..1", expected: []filter.Token{ {Type: filter.IDENT, Literal: "..1", Position: 0}, {Type: filter.EOF, Literal: "", Position: 3}, }, }, { name: "double dots with multi digit is identifier", input: "..25", expected: []filter.Token{ {Type: filter.IDENT, Literal: "..25", Position: 0}, {Type: filter.EOF, Literal: "", Position: 4}, }, }, { name: "parent directory path not confused with depth", input: "../foo", expected: []filter.Token{ {Type: filter.PATH, Literal: "../foo", Position: 0}, {Type: filter.EOF, Literal: "", Position: 6}, }, }, { name: "identifier with double dots followed by letter", input: "foo..bar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo..bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 8}, }, }, { name: "identifier with double dots and digit", input: "foo..1", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo..1", Position: 0}, {Type: filter.EOF, Literal: "", Position: 6}, }, }, { name: "double dots digit and identifier", input: "..2foo", expected: []filter.Token{ {Type: filter.IDENT, Literal: "..2foo", Position: 0}, {Type: filter.EOF, Literal: "", Position: 6}, }, }, { name: "ellipsis with identifier", input: "...foo", expected: []filter.Token{ {Type: filter.ELLIPSIS, Literal: "...", Position: 0}, {Type: filter.IDENT, Literal: "foo", Position: 3}, {Type: filter.EOF, Literal: "", Position: 6}, }, }, { name: "number ellipsis identifier (dependent depth syntax)", input: "1...foo", expected: []filter.Token{ {Type: filter.IDENT, Literal: "1", Position: 0}, {Type: filter.ELLIPSIS, Literal: "...", Position: 1}, {Type: filter.IDENT, Literal: "foo", Position: 4}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "identifier ellipsis number (dependency depth syntax)", input: "foo...1", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo", Position: 0}, {Type: filter.ELLIPSIS, Literal: "...", Position: 3}, {Type: filter.IDENT, Literal: "1", Position: 6}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "full depth syntax both directions", input: "1...foo...2", expected: []filter.Token{ {Type: filter.IDENT, Literal: "1", Position: 0}, {Type: filter.ELLIPSIS, Literal: "...", Position: 1}, {Type: filter.IDENT, Literal: "foo", Position: 4}, {Type: filter.ELLIPSIS, Literal: "...", Position: 7}, {Type: filter.IDENT, Literal: "2", Position: 10}, {Type: filter.EOF, Literal: "", Position: 11}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) for i, expected := range tt.expected { tok := lexer.NextToken() assert.Equal(t, expected.Type, tok.Type, "token %d type mismatch", i) assert.Equal(t, expected.Literal, tok.Literal, "token %d literal mismatch", i) assert.Equal(t, expected.Position, tok.Position, "token %d position mismatch", i) } }) } } func TestLexer_ComplexQueries(t *testing.T) { t.Parallel() tests := []struct { name string input string expected []filter.Token }{ { name: "attribute filter", input: "name=foo", expected: []filter.Token{ {Type: filter.IDENT, Literal: "name", Position: 0}, {Type: filter.EQUAL, Literal: "=", Position: 4}, {Type: filter.IDENT, Literal: "foo", Position: 5}, {Type: filter.EOF, Literal: "", Position: 8}, }, }, { name: "negated attribute filter", input: "!name=bar", expected: []filter.Token{ {Type: filter.BANG, Literal: "!", Position: 0}, {Type: filter.IDENT, Literal: "name", Position: 1}, {Type: filter.EQUAL, Literal: "=", Position: 5}, {Type: filter.IDENT, Literal: "bar", Position: 6}, {Type: filter.EOF, Literal: "", Position: 9}, }, }, { name: "negated path filter", input: "!./apps/legacy", expected: []filter.Token{ {Type: filter.BANG, Literal: "!", Position: 0}, {Type: filter.PATH, Literal: "./apps/legacy", Position: 1}, {Type: filter.EOF, Literal: "", Position: 14}, }, }, { name: "union of two filters", input: "./apps/* | name=bar", expected: []filter.Token{ {Type: filter.PATH, Literal: "./apps/*", Position: 0}, {Type: filter.PIPE, Literal: "|", Position: 9}, {Type: filter.IDENT, Literal: "name", Position: 11}, {Type: filter.EQUAL, Literal: "=", Position: 15}, {Type: filter.IDENT, Literal: "bar", Position: 16}, {Type: filter.EOF, Literal: "", Position: 19}, }, }, { name: "complex query with whitespace", input: "name=foo | !./legacy | ./apps/**", expected: []filter.Token{ {Type: filter.IDENT, Literal: "name", Position: 0}, {Type: filter.EQUAL, Literal: "=", Position: 4}, {Type: filter.IDENT, Literal: "foo", Position: 5}, {Type: filter.PIPE, Literal: "|", Position: 9}, {Type: filter.BANG, Literal: "!", Position: 11}, {Type: filter.PATH, Literal: "./legacy", Position: 12}, {Type: filter.PIPE, Literal: "|", Position: 21}, {Type: filter.PATH, Literal: "./apps/**", Position: 23}, {Type: filter.EOF, Literal: "", Position: 32}, }, }, { name: "hidden file with operator", input: ".env | .gitignore", expected: []filter.Token{ {Type: filter.IDENT, Literal: ".env", Position: 0}, {Type: filter.PIPE, Literal: "|", Position: 5}, {Type: filter.IDENT, Literal: ".gitignore", Position: 7}, {Type: filter.EOF, Literal: "", Position: 17}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) for i, expected := range tt.expected { tok := lexer.NextToken() assert.Equal(t, expected.Type, tok.Type, "token %d type mismatch", i) assert.Equal(t, expected.Literal, tok.Literal, "token %d literal mismatch", i) assert.Equal(t, expected.Position, tok.Position, "token %d position mismatch", i) } }) } } func TestLexer_EdgeCases(t *testing.T) { t.Parallel() tests := []struct { name string input string expected []filter.Token }{ { name: "empty input", input: "", expected: []filter.Token{ {Type: filter.EOF, Literal: "", Position: 0}, }, }, { name: "only whitespace", input: " \t\n ", expected: []filter.Token{ {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "single dot (invalid)", input: ".", expected: []filter.Token{ {Type: filter.ILLEGAL, Literal: ".", Position: 0}, {Type: filter.EOF, Literal: "", Position: 1}, }, }, { name: "special character now allowed", input: "@username", expected: []filter.Token{ {Type: filter.IDENT, Literal: "@username", Position: 0}, {Type: filter.EOF, Literal: "", Position: 9}, }, }, { name: "path with dashes and underscores", input: "./my-app_v2/foo-bar", expected: []filter.Token{ {Type: filter.PATH, Literal: "./my-app_v2/foo-bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 19}, }, }, { name: "tab in identifier", input: "foo\tbar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo\tbar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "special characters in path", input: "./app+test", expected: []filter.Token{ {Type: filter.PATH, Literal: "./app+test", Position: 0}, {Type: filter.EOF, Literal: "", Position: 10}, }, }, { name: "spaces in identifier", input: "foo bar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, { name: "spaces in path", input: "./my path/to file", expected: []filter.Token{ {Type: filter.PATH, Literal: "./my path/to file", Position: 0}, {Type: filter.EOF, Literal: "", Position: 17}, }, }, { name: "spaces with pipe separator", input: "foo bar | baz qux", expected: []filter.Token{ {Type: filter.IDENT, Literal: "foo bar", Position: 0}, {Type: filter.PIPE, Literal: "|", Position: 8}, {Type: filter.IDENT, Literal: "baz qux", Position: 10}, {Type: filter.EOF, Literal: "", Position: 17}, }, }, { name: "braced path", input: "{./apps/*}", expected: []filter.Token{ {Type: filter.LBRACE, Literal: "{", Position: 0}, {Type: filter.PATH, Literal: "./apps/*", Position: 1}, {Type: filter.RBRACE, Literal: "}", Position: 9}, {Type: filter.EOF, Literal: "", Position: 10}, }, }, { name: "braced path with spaces", input: "{my path/file}", expected: []filter.Token{ {Type: filter.LBRACE, Literal: "{", Position: 0}, {Type: filter.PATH, Literal: "my path/file", Position: 1}, {Type: filter.RBRACE, Literal: "}", Position: 13}, {Type: filter.EOF, Literal: "", Position: 14}, }, }, { name: "source filter with slash", input: "source=github.com/acme/foo/bar", expected: []filter.Token{ {Type: filter.IDENT, Literal: "source", Position: 0}, {Type: filter.EQUAL, Literal: "=", Position: 6}, {Type: filter.IDENT, Literal: "github.com/acme/foo/bar", Position: 7}, {Type: filter.EOF, Literal: "", Position: 30}, }, }, { name: "path filter with slash", input: "foo/bar", expected: []filter.Token{ {Type: filter.PATH, Literal: "foo/bar", Position: 0}, {Type: filter.EOF, Literal: "", Position: 7}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) for i, expected := range tt.expected { tok := lexer.NextToken() assert.Equal(t, expected.Type, tok.Type, "token %d type mismatch", i) assert.Equal(t, expected.Literal, tok.Literal, "token %d literal mismatch", i) assert.Equal(t, expected.Position, tok.Position, "token %d position mismatch", i) } }) } } func TestTokenType_String(t *testing.T) { t.Parallel() tests := []struct { expected string tokenType filter.TokenType }{ {"ILLEGAL", filter.ILLEGAL}, {"EOF", filter.EOF}, {"IDENT", filter.IDENT}, {"PATH", filter.PATH}, {"!", filter.BANG}, {"|", filter.PIPE}, {"=", filter.EQUAL}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.tokenType.String()) }) } } ================================================ FILE: internal/filter/matcher.go ================================================ package filter import ( "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" ) // MatchComponent checks if a single component matches an expression. // This is the shared core used by both Classifier and Evaluate. func MatchComponent(c component.Component, expr Expression) bool { switch node := expr.(type) { case *PathExpression: return matchPath(c, node) case *AttributeExpression: return matchAttribute(c, node) case *PrefixExpression: if node.Operator != "!" { return false } return MatchComponent(c, node.Right) case *InfixExpression: if node.Operator != "|" { return false } if !MatchComponent(c, node.Left) { return false } return MatchComponent(c, node.Right) case *GraphExpression: return MatchComponent(c, node.Target) case *GitExpression: return matchGit(c, node) default: return false } } // matchPath checks if a component matches a path expression. func matchPath(c component.Component, expr *PathExpression) bool { g := expr.Glob() componentPath := c.Path() // If the pattern is absolute, match against absolute path if filepath.IsAbs(expr.Value) { return g.Match(filepath.ToSlash(componentPath)) } // Try to get relative path from discovery context discoveryCtx := c.DiscoveryContext() if discoveryCtx != nil && discoveryCtx.WorkingDir != "" { relPath, err := filepath.Rel(discoveryCtx.WorkingDir, componentPath) if err == nil { return g.Match(filepath.ToSlash(relPath)) } } // Fall back to matching the path as-is return g.Match(filepath.ToSlash(componentPath)) } // matchAttribute checks if a component matches an attribute expression. // This handles attributes that can be evaluated without parsing (name, type, external). // For attributes requiring parsing (reading, source), this returns false. func matchAttribute(c component.Component, expr *AttributeExpression) bool { switch expr.Key { case AttributeName: return expr.Glob().Match(filepath.Base(c.Path())) case AttributeType: switch expr.Value { case AttributeTypeValueUnit: _, ok := c.(*component.Unit) return ok case AttributeTypeValueStack: _, ok := c.(*component.Stack) return ok } return false case AttributeExternal: switch expr.Value { case AttributeExternalValueTrue: return c.External() case AttributeExternalValueFalse: return !c.External() } return false case AttributeReading: // Reading attribute requires parsing, can't evaluate without parsed data return false case AttributeSource: // Source attribute requires parsing, can't evaluate without parsed data return false default: return false } } // matchGit checks if a component matches a git expression. // Components discovered from worktrees have a Ref set in their discovery context. func matchGit(c component.Component, expr *GitExpression) bool { discoveryCtx := c.DiscoveryContext() if discoveryCtx == nil || discoveryCtx.Ref == "" { return false } return discoveryCtx.Ref == expr.FromRef || discoveryCtx.Ref == expr.ToRef } ================================================ FILE: internal/filter/parser.go ================================================ package filter import ( "strconv" "strings" ) // Parser parses a filter query string into an AST. type Parser struct { lexer *Lexer errors []error originalQuery string curToken Token peekToken Token } // Operator precedence levels const ( _ int = iota LOWEST INTERSECTION // | PREFIX // ! ) // precedences maps token types to their precedence levels var precedences = map[TokenType]int{ PIPE: INTERSECTION, } // NewParser creates a new Parser for the given lexer. func NewParser(lexer *Lexer) *Parser { p := &Parser{ lexer: lexer, errors: []error{}, originalQuery: lexer.Input(), // Capture original input for diagnostics } // Read two tokens to initialize curToken and peekToken p.nextToken() p.nextToken() return p } // ParseExpression parses and returns an expression from the input. func (p *Parser) ParseExpression() (Expression, error) { expr := p.parseExpression(LOWEST) if expr == nil { if len(p.errors) > 0 { return nil, p.errors[0] } return nil, p.createError(ErrorCodeUnknown, "Parse error", "failed to parse expression") } if p.curToken.Type != EOF { return nil, p.createError(ErrorCodeUnexpectedToken, "Unexpected token", "Unexpected '"+p.curToken.Literal+"' after expression") } return expr, nil } // createError creates a ParseError with full context for rich diagnostics. func (p *Parser) createError(code ErrorCode, title, msg string) error { tokenLen := len(p.curToken.Literal) if tokenLen == 0 { tokenLen = 1 // Minimum length for underline } return NewParseErrorWithContext( title, msg, p.curToken.Position, p.curToken.Position, p.originalQuery, p.curToken.Literal, tokenLen, code, ) } // Errors returns any parsing errors that occurred. func (p *Parser) Errors() []error { return p.errors } // nextToken advances to the next token. func (p *Parser) nextToken() { p.curToken = p.peekToken p.peekToken = p.lexer.NextToken() } // parseExpression is the core recursive descent parser. func (p *Parser) parseExpression(precedence int) Expression { // Check for prefix depth (N...foo) or ellipsis (...foo) includeDependents := false dependentDepth := 0 // Check for N... (number followed by ellipsis = dependent depth) if isPurelyNumeric(p.curToken.Literal) && p.peekToken.Type == ELLIPSIS { includeDependents = true dependentDepth = parseDepth(p.curToken.Literal) p.nextToken() // consume number p.nextToken() // consume ellipsis } else if p.curToken.Type == ELLIPSIS { includeDependents = true p.nextToken() } // Check for caret (^) for exclusion excludeTarget := false if p.curToken.Type == CARET { excludeTarget = true p.nextToken() } var leftExpr Expression switch p.curToken.Type { case BANG: leftExpr = p.parsePrefixExpression() case PATH: leftExpr = p.parsePathFilter() case LBRACE: leftExpr = p.parseBracedPath() case LBRACKET: leftExpr = p.parseGitFilter() case IDENT: if p.peekToken.Type == EQUAL { leftExpr = p.parseAttributeFilter() break } attr, attrErr := NewAttributeExpression("name", p.curToken.Literal) if attrErr != nil { p.addErrorWithCode(ErrorCodeInvalidGlob, "Invalid glob pattern", "Invalid glob pattern in name filter: "+attrErr.Error()) return nil } leftExpr = attr p.nextToken() case ILLEGAL: p.addErrorWithCode(ErrorCodeIllegalToken, "Illegal token", "Unrecognized character '"+p.curToken.Literal+"'") return nil case EOF: p.addErrorWithCode(ErrorCodeUnexpectedEOF, "Unexpected end of input", "Expression is incomplete") return nil case PIPE: p.addErrorWithCode(ErrorCodeUnexpectedToken, "Unexpected token", "Missing left-hand side of '|' operator") case EQUAL, RBRACE, RBRACKET, ELLIPSIS, CARET: p.addErrorWithCode(ErrorCodeUnexpectedToken, "Unexpected token", "Unexpected '"+p.curToken.Literal+"'") return nil default: p.addErrorWithCode(ErrorCodeUnexpectedToken, "Unexpected token", "Unexpected '"+p.curToken.Literal+"'") return nil } if leftExpr == nil { return nil } target := leftExpr // Check for postfix ellipsis (foo... or foo...N) includeDependencies := false dependencyDepth := 0 if p.curToken.Type == ELLIPSIS { includeDependencies = true p.nextToken() // Check for ...N (ellipsis followed by number = dependency depth) if isPurelyNumeric(p.curToken.Literal) { dependencyDepth = parseDepth(p.curToken.Literal) p.nextToken() } } // If we have any graph operators, wrap in GraphExpression if includeDependents || includeDependencies || excludeTarget { leftExpr = &GraphExpression{ Target: target, IncludeDependents: includeDependents, IncludeDependencies: includeDependencies, ExcludeTarget: excludeTarget, DependentDepth: dependentDepth, DependencyDepth: dependencyDepth, } } for p.curToken.Type != EOF && precedence < p.curPrecedence() { switch p.curToken.Type { case PIPE: leftExpr = p.parseInfixExpression(leftExpr) case ILLEGAL, EOF, IDENT, PATH, BANG, EQUAL, LBRACE, RBRACE, LBRACKET, RBRACKET, ELLIPSIS, CARET: return leftExpr default: return leftExpr } } return leftExpr } // isPurelyNumeric returns true if the string contains only digits. func isPurelyNumeric(s string) bool { if len(s) == 0 { return false } for _, ch := range s { if ch < '0' || ch > '9' { return false } } return true } // parseDepth parses a depth value from a numeric string. // Returns 0 (unlimited) if parsing fails. Clamps to MaxTraversalDepth for very large values. func parseDepth(literal string) int { depth, err := strconv.Atoi(literal) if err != nil || depth < 0 { return 0 } if depth > MaxTraversalDepth { return MaxTraversalDepth } return depth } // parsePrefixExpression parses a prefix expression (e.g., "!name=foo"). // It collapses consecutive negations: !! becomes positive, !!! becomes negative, etc. func (p *Parser) parsePrefixExpression() Expression { // Count consecutive negation operators negationCount := 0 for p.curToken.Type == BANG { negationCount++ p.nextToken() } // Parse the inner expression inner := p.parseExpression(PREFIX) if inner == nil { // Clear any errors from parseExpression (like generic EOF error) // and add our specific error with the EOF title for consistency p.errors = nil p.addMissingOperandError("Unexpected end of input", "Missing target expression for '!' operator") return nil } // If even number of negations, they cancel out - return inner expression directly if negationCount%2 == 0 { return inner } // Odd number of negations - wrap in single PrefixExpression return &PrefixExpression{ Operator: "!", Right: inner, } } // parseInfixExpression parses an infix expression (e.g., "./apps/* | name=bar"). func (p *Parser) parseInfixExpression(left Expression) Expression { expression := &InfixExpression{ Operator: p.curToken.Literal, Left: left, } precedence := p.curPrecedence() p.nextToken() expression.Right = p.parseExpression(precedence) if expression.Right == nil { // Clear any errors from parseExpression (like generic EOF error) // and add our specific error with the EOF title for consistency p.errors = nil p.addMissingOperandError("Unexpected end of input", "Missing right-hand side of '|' operator") return nil } return expression } // parsePathFilter parses a path filter (e.g., "./apps/*"). func (p *Parser) parsePathFilter() Expression { expr, err := NewPathFilter(p.curToken.Literal) if err != nil { p.addErrorWithCode(ErrorCodeInvalidGlob, "Invalid glob pattern", "Invalid glob pattern '"+p.curToken.Literal+"': "+err.Error()) return nil } p.nextToken() return expr } // parseBracedPath parses a braced path filter (e.g., "{./apps/*}" or "{my path}"). func (p *Parser) parseBracedPath() Expression { // Capture opening brace position for error reporting openBracePos := p.curToken.Position // We're currently at LBRACE, move to the content p.nextToken() if p.curToken.Type == RBRACE { p.addErrorWithCode(ErrorCodeEmptyExpression, "Empty path expression", "Braced path expression cannot be empty") return nil } // Read everything until RBRACE as the path var pathParts []string for p.curToken.Type != RBRACE && p.curToken.Type != EOF { pathParts = append(pathParts, p.curToken.Literal) p.nextToken() } if p.curToken.Type != RBRACE { p.addErrorAtPosition(ErrorCodeMissingClosingBrace, "Unclosed path expression", "This braced path expression is missing a closing '}'", openBracePos) return nil } // Move past RBRACE p.nextToken() // Join all parts to form the complete path pathValue := strings.Join(pathParts, "") expr, err := NewPathFilter(pathValue) if err != nil { p.addErrorWithCode(ErrorCodeInvalidGlob, "Invalid glob pattern", "Invalid glob pattern '"+pathValue+"': "+err.Error()) return nil } return expr } // parseAttributeFilter parses an attribute filter (e.g., "name=foo"). func (p *Parser) parseAttributeFilter() Expression { key := p.curToken.Literal if !p.expectPeek(EQUAL) { return nil } p.nextToken() if p.curToken.Type != IDENT && p.curToken.Type != PATH { p.addErrorWithCode(ErrorCodeUnexpectedToken, "Attribute expression missing value", "Attribute expressions require a value after '='") return nil } value := p.curToken.Literal p.nextToken() expr, err := NewAttributeExpression(key, value) if err != nil { p.addErrorWithCode(ErrorCodeInvalidGlob, "Invalid glob pattern", "Invalid glob pattern in "+key+" filter: "+err.Error()) return nil } return expr } // parseGitFilter parses a Git filter expression (e.g., "[main...HEAD]" or "[main]"). func (p *Parser) parseGitFilter() Expression { // Capture opening bracket position for error reporting openBracketPos := p.curToken.Position // We're currently at LBRACKET, move to the content p.nextToken() if p.curToken.Type == RBRACKET { p.addErrorWithCode(ErrorCodeEmptyGitFilter, "Empty Git filter", "Git filter expression cannot be empty") return nil } // Read the first reference (can be IDENT or PATH-like) var fromRefParts []string for p.curToken.Type != RBRACKET && p.curToken.Type != ELLIPSIS && p.curToken.Type != EOF { fromRefParts = append(fromRefParts, p.curToken.Literal) p.nextToken() } if len(fromRefParts) == 0 { p.addErrorWithCode(ErrorCodeMissingGitRef, "Missing Git reference", "Expected Git reference in filter") return nil } fromRef := strings.Join(fromRefParts, "") // Check if there's an ellipsis and second reference if p.curToken.Type == ELLIPSIS { // Move past ellipsis p.nextToken() // Read the second reference var toRefParts []string for p.curToken.Type != RBRACKET && p.curToken.Type != EOF { toRefParts = append(toRefParts, p.curToken.Literal) p.nextToken() } if len(toRefParts) == 0 { p.addErrorWithCode(ErrorCodeMissingGitRef, "Missing Git reference", "Expected second Git reference after '...'") return nil } toRef := strings.Join(toRefParts, "") if p.curToken.Type != RBRACKET { p.addErrorAtPosition(ErrorCodeMissingClosingBracket, "Unclosed Git filter expression", "This Git-based expression is missing a closing ']'", openBracketPos) return nil } // Move past RBRACKET p.nextToken() return NewGitExpression(fromRef, toRef) } // Single reference case if p.curToken.Type != RBRACKET { p.addErrorAtPosition(ErrorCodeMissingClosingBracket, "Unclosed Git filter expression", "This Git-based expression is missing a closing ']'", openBracketPos) return nil } // Move past RBRACKET p.nextToken() return NewGitExpression(fromRef, "HEAD") } // expectPeek checks if the next token is of the expected type and advances if so. func (p *Parser) expectPeek(t TokenType) bool { if p.peekToken.Type == t { p.nextToken() return true } p.addError("expected next token to be " + t.String() + ", got " + p.peekToken.Type.String()) return false } // curPrecedence returns the precedence of the current token. func (p *Parser) curPrecedence() int { if p, ok := precedences[p.curToken.Type]; ok { return p } return LOWEST } // addError adds an error to the parser's error list. func (p *Parser) addError(msg string) { p.addErrorWithCode(ErrorCodeUnknown, "Parse error", msg) } // addErrorWithCode adds an error with a specific error code for hint lookup. func (p *Parser) addErrorWithCode(code ErrorCode, title, msg string) { tokenLen := len(p.curToken.Literal) if tokenLen == 0 { tokenLen = 1 // Minimum length for underline } err := NewParseErrorWithContext( title, msg, p.curToken.Position, p.curToken.Position, p.originalQuery, p.curToken.Literal, tokenLen, code, ) p.errors = append(p.errors, err) } // addMissingOperandError adds a MissingOperand error with a custom title. // This is used when a more specific error replaces a generic EOF error. func (p *Parser) addMissingOperandError(title, msg string) { tokenLen := len(p.curToken.Literal) if tokenLen == 0 { tokenLen = 1 // Minimum length for underline } err := NewParseErrorWithContext( title, msg, p.curToken.Position, p.curToken.Position, p.originalQuery, p.curToken.Literal, tokenLen, ErrorCodeMissingOperand, ) p.errors = append(p.errors, err) } // addErrorAtPosition adds an error with a specific error code and custom error position for caret placement. func (p *Parser) addErrorAtPosition(code ErrorCode, title, msg string, errorPosition int) { tokenLen := 1 // Single character underline for bracket errors err := NewParseErrorWithContext( title, msg, p.curToken.Position, errorPosition, p.originalQuery, p.curToken.Literal, tokenLen, code, ) p.errors = append(p.errors, err) } ================================================ FILE: internal/filter/parser_test.go ================================================ package filter_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParser_SimpleExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "simple name filter", input: "foo", expected: mustAttr(t, "name", "foo"), }, { name: "attribute filter", input: "name=bar", expected: mustAttr(t, "name", "bar"), }, { name: "type attribute filter", input: "type=unit", expected: mustAttr(t, "type", "unit"), }, { name: "path filter relative", input: "./apps/foo", expected: mustPath(t, "./apps/foo"), }, { name: "path filter absolute", input: "/absolute/path", expected: mustPath(t, "/absolute/path"), }, { name: "path filter with wildcard", input: "./apps/*", expected: mustPath(t, "./apps/*"), }, { name: "path filter with recursive wildcard", input: "./apps/**/foo", expected: mustPath(t, "./apps/**/foo"), }, { name: "braced path filter", input: "{./apps/*}", expected: mustPath(t, "./apps/*"), }, { name: "braced path without prefix", input: "{apps}", expected: mustPath(t, "apps"), }, { name: "braced path with spaces", input: "{my path/file}", expected: mustPath(t, "my path/file"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_PrefixExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "negated name filter", input: "!foo", expected: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "foo"), }, }, { name: "negated attribute filter", input: "!name=bar", expected: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "bar"), }, }, { name: "negated path filter", input: "!./apps/legacy", expected: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "./apps/legacy"), }, }, { name: "negated braced path filter", input: "!{./apps/legacy}", expected: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "./apps/legacy"), }, }, { name: "negated braced path filter with absolute path", input: "!{/absolute/path}", expected: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "/absolute/path"), }, }, { name: "double negation collapses to positive", input: "!!foo", expected: mustAttr(t, "name", "foo"), }, { name: "triple negation collapses to single negative", input: "!!!foo", expected: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "foo"), }, }, { name: "quadruple negation collapses to positive", input: "!!!!foo", expected: mustAttr(t, "name", "foo"), }, { name: "double negation with attribute filter", input: "!!name=bar", expected: mustAttr(t, "name", "bar"), }, { name: "double negation with path filter", input: "!!./apps/foo", expected: mustPath(t, "./apps/foo"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_InfixExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "union of two name filters", input: "foo | bar", expected: &filter.InfixExpression{ Left: mustAttr(t, "name", "foo"), Operator: "|", Right: mustAttr(t, "name", "bar"), }, }, { name: "union of attribute filters", input: "name=foo | name=bar", expected: &filter.InfixExpression{ Left: mustAttr(t, "name", "foo"), Operator: "|", Right: mustAttr(t, "name", "bar"), }, }, { name: "union of path and name filter", input: "./apps/* | name=bar", expected: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: mustAttr(t, "name", "bar"), }, }, { name: "union of three filters", input: "foo | bar | baz", expected: &filter.InfixExpression{ Left: &filter.InfixExpression{ Left: mustAttr(t, "name", "foo"), Operator: "|", Right: mustAttr(t, "name", "bar"), }, Operator: "|", Right: mustAttr(t, "name", "baz"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_ComplexExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "negated filter in union", input: "!foo | bar", expected: &filter.InfixExpression{ Left: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "foo"), }, Operator: "|", Right: mustAttr(t, "name", "bar"), }, }, { name: "union with negated second operand", input: "foo | !bar", expected: &filter.InfixExpression{ Left: mustAttr(t, "name", "foo"), Operator: "|", Right: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "bar"), }, }, }, { name: "complex mix of paths and attributes", input: "./apps/* | !./legacy | name=foo", expected: &filter.InfixExpression{ Left: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: &filter.PrefixExpression{ Operator: "!", Right: mustPath(t, "./legacy"), }, }, Operator: "|", Right: mustAttr(t, "name", "foo"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_ErrorCases(t *testing.T) { t.Parallel() tests := []struct { name string input string expectError bool }{ { name: "empty input", input: "", expectError: true, }, { name: "only operator", input: "!", expectError: true, }, { name: "missing value after equal", input: "name=", expectError: true, }, { name: "missing right side of union", input: "foo |", expectError: true, }, { name: "invalid token", input: "foo|", expectError: true, }, { name: "trailing pipe", input: "foo | bar |", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() if tt.expectError { require.Error(t, err) assert.Nil(t, expr) } else { require.NoError(t, err) assert.NotNil(t, expr) } }) } } func TestParser_StringRepresentation(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ { name: "simple name filter", input: "foo", expected: "name=foo", }, { name: "path filter", input: "./apps/*", expected: "./apps/*", }, { name: "negated filter", input: "!foo", expected: "!name=foo", }, { name: "union filter", input: "foo | bar", expected: "name=foo | name=bar", }, { name: "graph expression with dependents", input: "...foo", expected: "...name=foo", }, { name: "graph expression with dependencies", input: "foo...", expected: "name=foo...", }, { name: "graph expression with dependent depth", input: "1...foo", expected: "1...name=foo", }, { name: "graph expression with dependency depth", input: "foo...1", expected: "name=foo...1", }, { name: "graph expression with both depths", input: "2...foo...3", expected: "2...name=foo...3", }, { name: "graph expression with caret", input: "...^foo...", expected: "...^name=foo...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr.String()) }) } } func TestParser_GraphExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "prefix ellipsis - dependents only", input: "...foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, }, { name: "postfix ellipsis - dependencies only", input: "foo...", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, }, }, { name: "both prefix and postfix ellipsis", input: "...foo...", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: false, }, }, { name: "caret - exclude target only", input: "^foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: false, IncludeDependencies: false, ExcludeTarget: true, }, }, { name: "caret with prefix ellipsis", input: "...^foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: true, }, }, { name: "caret with postfix ellipsis", input: "^foo...", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: true, }, }, { name: "caret with both ellipsis", input: "...^foo...", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: true, }, }, { name: "graph expression with path filter", input: "...{./apps/foo}", expected: &filter.GraphExpression{ Target: mustPath(t, "./apps/foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, }, { name: "graph expression with path filter and postfix ellipsis", input: "./apps/foo...", expected: &filter.GraphExpression{ Target: mustPath(t, "./apps/foo"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, }, }, { name: "graph expression with attribute filter", input: "...name=bar", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "bar"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, }, { name: "graph expression with braced path and postfix ellipsis", input: "{./apps/foo}...", expected: &filter.GraphExpression{ Target: mustPath(t, "./apps/foo"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, }, }, { name: "graph expression with braced path and both ellipsis", input: "...{./apps/foo}...", expected: &filter.GraphExpression{ Target: mustPath(t, "./apps/foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: false, }, }, { name: "graph expression with braced path, caret, and both ellipsis", input: "...^{./apps/foo}...", expected: &filter.GraphExpression{ Target: mustPath(t, "./apps/foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: true, }, }, { name: "depth-limited prefix - direct dependents only", input: "1...foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 1, }, }, { name: "depth-limited postfix - direct dependencies only", input: "foo...1", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, DependencyDepth: 1, }, }, { name: "depth-limited both directions", input: "2...foo...3", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: false, DependentDepth: 2, DependencyDepth: 3, }, }, { name: "depth-limited with caret", input: "1...^foo...2", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: true, DependentDepth: 1, DependencyDepth: 2, }, }, { name: "depth-limited with multi-digit depth", input: "10...foo...25", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: false, DependentDepth: 10, DependencyDepth: 25, }, }, { name: "very large depth clamps to max", input: "999999999...foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: filter.MaxTraversalDepth, }, }, { name: "overflow depth falls back to unlimited", input: "99999999999999999999999...foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 0, }, }, // Numeric directory edge cases - testing disambiguation { name: "numeric dir with depth - number before ellipsis is depth", input: "1...1", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "1"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 1, }, }, { name: "numeric dir escape hatch - braced path for target with dependency depth", input: "{1}...1", expected: &filter.GraphExpression{ Target: mustPath(t, "1"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, DependencyDepth: 1, }, }, { name: "numeric dir escape hatch - braced path for target with dependent depth", input: "1...{1}", expected: &filter.GraphExpression{ Target: mustPath(t, "1"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 1, }, }, { name: "numeric dir escape hatch - explicit name attribute with dependency depth", input: "name=1...1", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "1"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, DependencyDepth: 1, }, }, { name: "numeric dir escape hatch - explicit name attribute with dependent depth", input: "1...name=1", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "1"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 1, }, }, { name: "numeric dir full escape - both directions with braces", input: "1...{1}...1", expected: &filter.GraphExpression{ Target: mustPath(t, "1"), IncludeDependents: true, IncludeDependencies: true, ExcludeTarget: false, DependentDepth: 1, DependencyDepth: 1, }, }, { name: "alphanumeric dir not confused with depth", input: "1...1foo", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "1foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, DependentDepth: 1, }, }, { name: "alphanumeric dir not confused with depth - postfix", input: "foo1...1", expected: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo1"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, DependencyDepth: 1, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) graphExpr, ok := expr.(*filter.GraphExpression) require.True(t, ok, "Expected GraphExpression, got %T", expr) assert.Equal(t, tt.expected.(*filter.GraphExpression).IncludeDependents, graphExpr.IncludeDependents) assert.Equal(t, tt.expected.(*filter.GraphExpression).IncludeDependencies, graphExpr.IncludeDependencies) assert.Equal(t, tt.expected.(*filter.GraphExpression).ExcludeTarget, graphExpr.ExcludeTarget) assert.Equal(t, tt.expected.(*filter.GraphExpression).Target, graphExpr.Target) assert.Equal(t, tt.expected.(*filter.GraphExpression).DependentDepth, graphExpr.DependentDepth) assert.Equal(t, tt.expected.(*filter.GraphExpression).DependencyDepth, graphExpr.DependencyDepth) }) } } func TestParser_GraphExpressionCombinations(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "graph expression in union - left side", input: "...foo | bar", expected: &filter.InfixExpression{ Left: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, Operator: "|", Right: mustAttr(t, "name", "bar"), }, }, { name: "graph expression in union - right side", input: "foo | bar...", expected: &filter.InfixExpression{ Left: mustAttr(t, "name", "foo"), Operator: "|", Right: &filter.GraphExpression{ Target: mustAttr(t, "name", "bar"), IncludeDependents: false, IncludeDependencies: true, ExcludeTarget: false, }, }, }, { name: "negated graph expression", input: "!...foo", expected: &filter.PrefixExpression{ Operator: "!", Right: &filter.GraphExpression{ Target: mustAttr(t, "name", "foo"), IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, }, }, { name: "graph expression with negation inside", input: "...!foo", expected: &filter.GraphExpression{ Target: &filter.PrefixExpression{ Operator: "!", Right: mustAttr(t, "name", "foo"), }, IncludeDependents: true, IncludeDependencies: false, ExcludeTarget: false, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_GraphExpressionErrors(t *testing.T) { t.Parallel() tests := []struct { name string input string expectError bool }{ { name: "ellipsis only", input: "...", expectError: true, }, { name: "caret only", input: "^", expectError: true, }, { name: "ellipsis followed by operator", input: "... |", expectError: true, }, { name: "caret followed by operator", input: "^ |", expectError: true, }, { name: "incomplete ellipsis", input: "..foo", expectError: false, // This parses as name filter "..foo", not an error }, { name: "depth without target", input: "1...", expectError: true, }, { name: "depth without target and trailing space", input: "1... ", expectError: true, }, { name: "double depth no target", input: "1......2", expectError: true, // 1... then ...2 with no target between }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() if tt.expectError { require.Error(t, err) assert.Nil(t, expr) return } // For non-error cases, just verify it parses if err != nil { t.Logf("Unexpected error: %v", err) } }) } } func TestParser_GitFilterExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "single Git reference", input: "[main]", expected: filter.NewGitExpression("main", "HEAD"), }, { name: "two Git references with ellipsis", input: "[main...HEAD]", expected: filter.NewGitExpression("main", "HEAD"), }, { name: "Git reference with branch name", input: "[feature-branch]", expected: filter.NewGitExpression("feature-branch", "HEAD"), }, { name: "Git reference with commit SHA", input: "[abc123...def456]", expected: filter.NewGitExpression("abc123", "def456"), }, { name: "Git reference with tag", input: "[v1.0.0...v2.0.0]", expected: filter.NewGitExpression("v1.0.0", "v2.0.0"), }, { name: "Git reference with relative ref", input: "[HEAD~1...HEAD]", expected: filter.NewGitExpression("HEAD~1", "HEAD"), }, { name: "Git reference with underscore in branch name", input: "[feature_branch]", expected: filter.NewGitExpression("feature_branch", "HEAD"), }, { name: "Git reference with slash in branch name", input: "[feature/name]", expected: filter.NewGitExpression("feature/name", "HEAD"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_GitFilterWithOtherExpressions(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "Git filter with negation", input: "![main...HEAD]", expected: &filter.PrefixExpression{ Operator: "!", Right: filter.NewGitExpression("main", "HEAD"), }, }, { name: "Git filter with path filter intersection", input: "[main...HEAD] | ./apps/*", expected: &filter.InfixExpression{ Left: filter.NewGitExpression("main", "HEAD"), Operator: "|", Right: mustPath(t, "./apps/*"), }, }, { name: "path filter with Git filter intersection", input: "./apps/* | [main...HEAD]", expected: &filter.InfixExpression{ Left: mustPath(t, "./apps/*"), Operator: "|", Right: filter.NewGitExpression("main", "HEAD"), }, }, { name: "Git filter with name filter intersection", input: "[main...HEAD] | name=app", expected: &filter.InfixExpression{ Left: filter.NewGitExpression("main", "HEAD"), Operator: "|", Right: mustAttr(t, "name", "app"), }, }, { name: "Git filter with graph expression", input: "[main...HEAD] | app...", expected: &filter.InfixExpression{ Left: filter.NewGitExpression("main", "HEAD"), Operator: "|", Right: &filter.GraphExpression{ Target: mustAttr(t, "name", "app"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr) }) } } func TestParser_GitFilterErrors(t *testing.T) { t.Parallel() tests := []struct { name string input string expectError bool }{ { name: "empty Git filter", input: "[]", expectError: true, }, { name: "unclosed Git filter", input: "[main", expectError: true, }, { name: "Git filter with only ellipsis", input: "[...]", expectError: true, }, { name: "Git filter with ellipsis but no second ref", input: "[main...]", expectError: true, }, { name: "Git filter with only closing bracket", input: "]", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() if tt.expectError { require.Error(t, err) assert.Nil(t, expr) } else { require.NoError(t, err) } }) } } // TestParser_GitFilterAsGraphExpressionTarget tests parsing of combined git + graph expressions // where a GitExpression is used as the target of a GraphExpression. func TestParser_GitFilterAsGraphExpressionTarget(t *testing.T) { t.Parallel() tests := []struct { expected filter.Expression name string input string }{ { name: "dependencies of git changes - postfix ellipsis", input: "[main...HEAD]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, }, }, { name: "dependents of git changes - prefix ellipsis", input: "...[main...HEAD]", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, }, }, { name: "both directions of git changes - issue #5307 pattern", input: "...[main...HEAD]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, }, }, { name: "exclude target with dependencies of git changes", input: "^[main...HEAD]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: true, }, }, { name: "exclude target with dependents of git changes", input: "...^[main...HEAD]", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: true, }, }, { name: "exclude target with both directions of git changes", input: "...^[main...HEAD]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: true, }, }, { name: "single git ref with dependencies", input: "[main]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, }, }, { name: "single git ref with dependents", input: "...[main]", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("main", "HEAD"), IncludeDependencies: false, IncludeDependents: true, ExcludeTarget: false, }, }, { name: "git ref with commit SHA and both directions", input: "...[abc123...def456]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("abc123", "def456"), IncludeDependencies: true, IncludeDependents: true, ExcludeTarget: false, }, }, { name: "git ref with relative ref (HEAD~1) and dependencies", input: "[HEAD~1...HEAD]...", expected: &filter.GraphExpression{ Target: filter.NewGitExpression("HEAD~1", "HEAD"), IncludeDependencies: true, IncludeDependents: false, ExcludeTarget: false, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) graphExpr, ok := expr.(*filter.GraphExpression) require.True(t, ok, "Expected GraphExpression, got %T", expr) expectedGraph := tt.expected.(*filter.GraphExpression) assert.Equal(t, expectedGraph.IncludeDependents, graphExpr.IncludeDependents, "IncludeDependents mismatch") assert.Equal(t, expectedGraph.IncludeDependencies, graphExpr.IncludeDependencies, "IncludeDependencies mismatch") assert.Equal(t, expectedGraph.ExcludeTarget, graphExpr.ExcludeTarget, "ExcludeTarget mismatch") gitExpr, ok := graphExpr.Target.(*filter.GitExpression) require.True(t, ok, "Expected GitExpression as target, got %T", graphExpr.Target) expectedGit := expectedGraph.Target.(*filter.GitExpression) assert.Equal(t, expectedGit.FromRef, gitExpr.FromRef, "FromRef mismatch") assert.Equal(t, expectedGit.ToRef, gitExpr.ToRef, "ToRef mismatch") }) } } // TestParser_GitFilterAsGraphExpressionTarget_StringRepresentation tests that // combined git + graph expressions produce correct string representations. func TestParser_GitFilterAsGraphExpressionTarget_StringRepresentation(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ { name: "dependencies of git changes", input: "[main...HEAD]...", expected: "[main...HEAD]...", }, { name: "dependents of git changes", input: "...[main...HEAD]", expected: "...[main...HEAD]", }, { name: "both directions of git changes", input: "...[main...HEAD]...", expected: "...[main...HEAD]...", }, { name: "exclude target with both directions", input: "...^[main...HEAD]...", expected: "...^[main...HEAD]...", }, { name: "single ref defaults to HEAD", input: "[main]...", expected: "[main...HEAD]...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() lexer := filter.NewLexer(tt.input) parser := filter.NewParser(lexer) expr, err := parser.ParseExpression() require.NoError(t, err) assert.Equal(t, tt.expected, expr.String()) }) } } ================================================ FILE: internal/filter/telemetry.go ================================================ // Package filter provides telemetry support for git worktree operations and filter evaluation. package filter import ( "context" "github.com/gruntwork-io/terragrunt/internal/telemetry" ) // Telemetry operation names for git worktree and filter operations. const ( // Git worktree operations TelemetryOpGitWorktreeCreate = "git_worktree_create" TelemetryOpGitWorktreeRemove = "git_worktree_remove" TelemetryOpGitWorktreesCreate = "git_worktrees_create" TelemetryOpGitWorktreesCleanup = "git_worktrees_cleanup" TelemetryOpGitDiff = "git_diff" TelemetryOpGitWorktreeDiscovery = "git_worktree_discovery" TelemetryOpGitWorktreeStackWalk = "git_worktree_stack_walk" TelemetryOpGitWorktreeFilterApply = "git_worktree_filter_apply" // Filter evaluation operations TelemetryOpFilterEvaluate = "filter_evaluate" TelemetryOpFilterParse = "filter_parse" TelemetryOpGitFilterExpand = "git_filter_expand" TelemetryOpGitFilterEvaluate = "git_filter_evaluate" TelemetryOpGraphFilterTraverse = "graph_filter_traverse" ) // Telemetry attribute keys for git worktree operations. const ( AttrGitRef = "git.ref" AttrGitFromRef = "git.from_ref" AttrGitToRef = "git.to_ref" AttrGitWorktreeDir = "git.worktree_dir" AttrGitWorkingDir = "git.working_dir" AttrGitRefCount = "git.ref_count" AttrGitDiffAdded = "git.diff.added_count" AttrGitDiffRemoved = "git.diff.removed_count" AttrGitDiffChanged = "git.diff.changed_count" // Repository identification attributes AttrGitRepoRemote = "git.repo.remote" AttrGitRepoBranch = "git.repo.branch" AttrGitRepoCommit = "git.repo.commit" AttrFilterQuery = "filter.query" AttrFilterType = "filter.type" AttrFilterCount = "filter.count" AttrComponentCount = "component.count" AttrResultCount = "result.count" AttrWorktreePairCount = "worktree.pair_count" ) // TraceGitWorktreeCreate wraps a git worktree create operation with telemetry. // The underlying Telemeter.Collect handles nil/unconfigured telemetry gracefully. func TraceGitWorktreeCreate(ctx context.Context, ref, worktreeDir, repoRemote, repoBranch, repoCommit string, fn func(ctx context.Context) error) error { attrs := map[string]any{ AttrGitRef: ref, AttrGitWorktreeDir: worktreeDir, } if repoRemote != "" { attrs[AttrGitRepoRemote] = repoRemote } if repoBranch != "" { attrs[AttrGitRepoBranch] = repoBranch } if repoCommit != "" { attrs[AttrGitRepoCommit] = repoCommit } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreeCreate, attrs, fn) } // TraceGitWorktreeRemove wraps a git worktree remove operation with telemetry. func TraceGitWorktreeRemove(ctx context.Context, ref, worktreeDir string, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreeRemove, map[string]any{ AttrGitRef: ref, AttrGitWorktreeDir: worktreeDir, }, fn) } // TraceGitWorktreesCreate wraps multiple git worktree create operations with telemetry. func TraceGitWorktreesCreate(ctx context.Context, workingDir string, refCount int, repoRemote, repoBranch, repoCommit string, fn func(ctx context.Context) error) error { attrs := map[string]any{ AttrGitWorkingDir: workingDir, AttrGitRefCount: refCount, } if repoRemote != "" { attrs[AttrGitRepoRemote] = repoRemote } if repoBranch != "" { attrs[AttrGitRepoBranch] = repoBranch } if repoCommit != "" { attrs[AttrGitRepoCommit] = repoCommit } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreesCreate, attrs, fn) } // TraceGitWorktreesCleanup wraps git worktrees cleanup with telemetry. func TraceGitWorktreesCleanup(ctx context.Context, pairCount int, repoRemote string, fn func(ctx context.Context) error) error { attrs := map[string]any{ AttrWorktreePairCount: pairCount, } if repoRemote != "" { attrs[AttrGitRepoRemote] = repoRemote } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreesCleanup, attrs, fn) } // TraceGitDiff wraps a git diff operation with telemetry. func TraceGitDiff(ctx context.Context, fromRef, toRef, repoRemote string, fn func(ctx context.Context) error) error { attrs := map[string]any{ AttrGitFromRef: fromRef, AttrGitToRef: toRef, } if repoRemote != "" { attrs[AttrGitRepoRemote] = repoRemote } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitDiff, attrs, fn) } // TraceGitWorktreeDiscovery wraps git worktree discovery operations with telemetry. func TraceGitWorktreeDiscovery(ctx context.Context, pairCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreeDiscovery, map[string]any{ AttrWorktreePairCount: pairCount, }, fn) } // TraceGitWorktreeStackWalk wraps git worktree stack walking operations with telemetry. func TraceGitWorktreeStackWalk(ctx context.Context, fromRef, toRef string, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreeStackWalk, map[string]any{ AttrGitFromRef: fromRef, AttrGitToRef: toRef, }, fn) } // TraceGitWorktreeFilterApply wraps filter application to git worktrees with telemetry. func TraceGitWorktreeFilterApply(ctx context.Context, filterCount, resultCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitWorktreeFilterApply, map[string]any{ AttrFilterCount: filterCount, AttrResultCount: resultCount, }, fn) } // TraceFilterEvaluate wraps filter evaluation with telemetry. func TraceFilterEvaluate(ctx context.Context, filterCount, componentCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpFilterEvaluate, map[string]any{ AttrFilterCount: filterCount, AttrComponentCount: componentCount, }, fn) } // TraceFilterParse wraps filter parsing with telemetry. func TraceFilterParse(ctx context.Context, query string, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpFilterParse, map[string]any{ AttrFilterQuery: query, }, fn) } // TraceGitFilterExpand wraps git filter expansion with telemetry. func TraceGitFilterExpand(ctx context.Context, fromRef, toRef string, addedCount, removedCount, changedCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitFilterExpand, map[string]any{ AttrGitFromRef: fromRef, AttrGitToRef: toRef, AttrGitDiffAdded: addedCount, AttrGitDiffRemoved: removedCount, AttrGitDiffChanged: changedCount, }, fn) } // TraceGitFilterEvaluate wraps git filter evaluation with telemetry. func TraceGitFilterEvaluate(ctx context.Context, fromRef, toRef string, componentCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGitFilterEvaluate, map[string]any{ AttrGitFromRef: fromRef, AttrGitToRef: toRef, AttrComponentCount: componentCount, }, fn) } // TraceGraphFilterTraverse wraps graph filter traversal with telemetry. func TraceGraphFilterTraverse(ctx context.Context, filterType string, componentCount int, fn func(ctx context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpGraphFilterTraverse, map[string]any{ AttrFilterType: filterType, AttrComponentCount: componentCount, }, fn) } ================================================ FILE: internal/filter/telemetry_test.go ================================================ package filter_test import ( "context" "testing" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTraceGitWorktreeCreate_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreeCreate(context.Background(), "main", "/tmp/worktree", "git@github.com:org/repo.git", "main", "abc123", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreeRemove_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreeRemove(context.Background(), "main", "/tmp/worktree", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreesCreate_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreesCreate(context.Background(), "/work", 2, "git@github.com:org/repo.git", "main", "abc123", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreesCleanup_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreesCleanup(context.Background(), 2, "git@github.com:org/repo.git", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitDiff_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitDiff(context.Background(), "main", "HEAD", "git@github.com:org/repo.git", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreeDiscovery_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreeDiscovery(context.Background(), 3, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreeStackWalk_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreeStackWalk(context.Background(), "main", "feature", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitWorktreeFilterApply_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitWorktreeFilterApply(context.Background(), 3, 5, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceFilterEvaluate_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceFilterEvaluate(context.Background(), 5, 10, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceFilterParse_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceFilterParse(context.Background(), "name=foo", func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitFilterExpand_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitFilterExpand(context.Background(), "main", "HEAD", 3, 1, 5, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGitFilterEvaluate_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGitFilterEvaluate(context.Background(), "main", "HEAD", 10, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTraceGraphFilterTraverse_NoTelemeter(t *testing.T) { t.Parallel() called := false err := filter.TraceGraphFilterTraverse(context.Background(), "dependencies", 5, func(ctx context.Context) error { called = true return nil }) require.NoError(t, err) assert.True(t, called, "callback should be called even without telemeter") } func TestTelemetryConstants(t *testing.T) { t.Parallel() // Verify operation names are unique and well-formed opNames := []string{ filter.TelemetryOpGitWorktreeCreate, filter.TelemetryOpGitWorktreeRemove, filter.TelemetryOpGitWorktreesCreate, filter.TelemetryOpGitWorktreesCleanup, filter.TelemetryOpGitDiff, filter.TelemetryOpGitWorktreeDiscovery, filter.TelemetryOpGitWorktreeStackWalk, filter.TelemetryOpGitWorktreeFilterApply, filter.TelemetryOpFilterEvaluate, filter.TelemetryOpFilterParse, filter.TelemetryOpGitFilterExpand, filter.TelemetryOpGitFilterEvaluate, filter.TelemetryOpGraphFilterTraverse, } seen := make(map[string]bool) for _, name := range opNames { assert.NotEmpty(t, name, "operation name should not be empty") assert.False(t, seen[name], "operation name should be unique: %s", name) seen[name] = true } // Verify attribute keys are well-formed attrKeys := []string{ filter.AttrGitRef, filter.AttrGitFromRef, filter.AttrGitToRef, filter.AttrGitWorktreeDir, filter.AttrGitWorkingDir, filter.AttrGitRefCount, filter.AttrGitDiffAdded, filter.AttrGitDiffRemoved, filter.AttrGitDiffChanged, filter.AttrGitRepoRemote, filter.AttrGitRepoBranch, filter.AttrGitRepoCommit, filter.AttrFilterQuery, filter.AttrFilterType, filter.AttrFilterCount, filter.AttrComponentCount, filter.AttrResultCount, filter.AttrWorktreePairCount, } seenAttrs := make(map[string]bool) for _, key := range attrKeys { assert.NotEmpty(t, key, "attribute key should not be empty") assert.False(t, seenAttrs[key], "attribute key should be unique: %s", key) seenAttrs[key] = true } } ================================================ FILE: internal/filter/token.go ================================================ package filter // TokenType represents the type of a token. type TokenType int const ( // ILLEGAL represents an unknown token ILLEGAL TokenType = iota // EOF represents the end of the input EOF // IDENT represents an identifier (e.g., "foo", "name") IDENT // PATH represents a path (starts with ./, ../, or /) PATH // Operators BANG // negation operator (!) PIPE // intersection operator (|) EQUAL // attribute assignment (=) // Delimiters LBRACE // left brace ({) RBRACE // right brace (}) LBRACKET // left bracket ([) RBRACKET // right bracket (]) // Graph operators ELLIPSIS // ellipsis operator (...) CARET // caret operator (^) ) // String returns a string representation of the token type for debugging. func (t TokenType) String() string { switch t { case ILLEGAL: return "ILLEGAL" case EOF: return "EOF" case IDENT: return "IDENT" case PATH: return "PATH" case BANG: return "!" case PIPE: return "|" case EQUAL: return "=" case LBRACE: return "{" case RBRACE: return "}" case LBRACKET: return "[" case RBRACKET: return "]" case ELLIPSIS: return "..." case CARET: return "^" default: return "UNKNOWN" } } // Token represents a lexical token with its type, literal value, and position. type Token struct { Literal string Type TokenType Position int } // NewToken creates a new token with the given type, literal, and position. func NewToken(tokenType TokenType, literal string, position int) Token { return Token{ Type: tokenType, Literal: literal, Position: position, } } ================================================ FILE: internal/filter/walk.go ================================================ package filter // WalkExpressions traverses the expression tree depth-first, calling fn for each node. // The traversal continues to child nodes only if fn returns true. // For GraphExpression nodes, traversal continues into the Target expression. // For PrefixExpression nodes, traversal continues into the Right expression. // For InfixExpression nodes, traversal continues into both Left and Right expressions. func WalkExpressions(expr Expression, fn func(Expression) bool) { if expr == nil { return } if !fn(expr) { return } switch node := expr.(type) { case *GraphExpression: WalkExpressions(node.Target, fn) case *PrefixExpression: WalkExpressions(node.Right, fn) case *InfixExpression: WalkExpressions(node.Left, fn) WalkExpressions(node.Right, fn) } } ================================================ FILE: internal/gcphelper/config.go ================================================ // Package gcphelper provides helper functions for working with GCP services. package gcphelper import ( "context" "encoding/json" "os" "cloud.google.com/go/storage" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "golang.org/x/oauth2" "golang.org/x/oauth2/jwt" "google.golang.org/api/impersonate" "google.golang.org/api/option" ) const ( tokenURL = "https://oauth2.googleapis.com/token" credTypeServiceAccount = "service_account" credTypeAuthorizedUser = "authorized_user" credTypeImpersonatedServiceAccount = "impersonated_service_account" credTypeExternalAccount = "external_account" ) // GCPSessionConfig is a representation of the configuration options for a GCP Config type GCPSessionConfig struct { Credentials string AccessToken string ImpersonateServiceAccount string ImpersonateServiceAccountDelegates []string } // GCPConfigBuilder constructs GCP client options using the builder pattern. type GCPConfigBuilder struct { sessionConfig *GCPSessionConfig env map[string]string } // NewGCPConfigBuilder creates a new GCPConfigBuilder. func NewGCPConfigBuilder() *GCPConfigBuilder { return &GCPConfigBuilder{} } // WithSessionConfig sets the GCP session configuration. func (b *GCPConfigBuilder) WithSessionConfig(config *GCPSessionConfig) *GCPConfigBuilder { b.sessionConfig = config return b } // WithEnv sets the environment variables to use for credential resolution. func (b *GCPConfigBuilder) WithEnv(env map[string]string) *GCPConfigBuilder { b.env = env return b } // BuildGCSClient builds a GCS storage client from the configured options. func (b *GCPConfigBuilder) BuildGCSClient(ctx context.Context) (*storage.Client, error) { clientOpts, err := b.Build(ctx) if err != nil { return nil, err } gcsClient, err := storage.NewClient(ctx, clientOpts...) if err != nil { return nil, errors.Errorf("Error creating GCS client: %w", err) } return gcsClient, nil } // Build returns GCP client options from the configured session config and env. func (b *GCPConfigBuilder) Build(ctx context.Context) ([]option.ClientOption, error) { gcpCfg := b.sessionConfig env := b.env var clientOpts []option.ClientOption envCreds, err := createGCPCredentialsFromEnv(env) if err != nil { return nil, err } if envCreds != nil { clientOpts = append(clientOpts, envCreds) } else if gcpCfg != nil && gcpCfg.Credentials != "" { // Use credentials file from config credOpt, err := credentialsFileOption(gcpCfg.Credentials) if err != nil { return nil, err } clientOpts = append(clientOpts, credOpt) } else if gcpCfg != nil && gcpCfg.AccessToken != "" { // Use access token from config tokenSource := oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: gcpCfg.AccessToken, }) clientOpts = append(clientOpts, option.WithTokenSource(tokenSource)) } else if oauthAccessToken := env["GOOGLE_OAUTH_ACCESS_TOKEN"]; oauthAccessToken != "" { // Use OAuth access token from environment tokenSource := oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: oauthAccessToken, }) clientOpts = append(clientOpts, option.WithTokenSource(tokenSource)) } else if env["GOOGLE_CREDENTIALS"] != "" { // Use GOOGLE_CREDENTIALS from environment (can be file path or JSON content) clientOpt, err := createGCPCredentialsFromGoogleCredentialsEnv(ctx, env) if err != nil { return nil, err } if clientOpt != nil { clientOpts = append(clientOpts, clientOpt) } } // Handle service account impersonation. // When impersonation is configured, the impersonation token source replaces // any base credentials. The impersonate library uses Application Default // Credentials internally as the source identity. if gcpCfg != nil && gcpCfg.ImpersonateServiceAccount != "" { ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ TargetPrincipal: gcpCfg.ImpersonateServiceAccount, Scopes: []string{storage.ScopeFullControl}, Delegates: gcpCfg.ImpersonateServiceAccountDelegates, }, clientOpts...) if err != nil { return nil, errors.Errorf("Error creating impersonation token source: %w", err) } clientOpts = []option.ClientOption{option.WithTokenSource(ts)} } return clientOpts, nil } // createGCPCredentialsFromEnv creates GCP credentials from GOOGLE_APPLICATION_CREDENTIALS environment variable in env // It looks for GOOGLE_APPLICATION_CREDENTIALS and returns a ClientOption that can be used // with Google Cloud clients. Returns nil if the environment variable is not set. func createGCPCredentialsFromEnv(env map[string]string) (option.ClientOption, error) { if len(env) == 0 { return nil, nil } credentialsFile := env["GOOGLE_APPLICATION_CREDENTIALS"] if credentialsFile == "" { return nil, nil } return credentialsFileOption(credentialsFile) } // credentialsFileOption reads a GCP credentials JSON file, detects its type, // and returns the appropriate ClientOption. func credentialsFileOption(filename string) (option.ClientOption, error) { data, err := os.ReadFile(filename) if err != nil { return nil, errors.Errorf("Error reading credentials file %s: %w", filename, err) } var meta struct { Type string `json:"type"` } if err := json.Unmarshal(data, &meta); err != nil { return nil, errors.Errorf("Error parsing credentials file %s: %w", filename, err) } credType, err := credentialsTypeFromString(meta.Type) if err != nil { return nil, err } return option.WithAuthCredentialsFile(credType, filename), nil } // credentialsTypeFromString maps the "type" field in a GCP credentials JSON // file to the corresponding option.CredentialsType. func credentialsTypeFromString(t string) (option.CredentialsType, error) { switch t { case credTypeServiceAccount: return option.ServiceAccount, nil case credTypeAuthorizedUser: return option.AuthorizedUser, nil case credTypeImpersonatedServiceAccount: return option.ImpersonatedServiceAccount, nil case credTypeExternalAccount: return option.ExternalAccount, nil default: return "", errors.Errorf("Unsupported GCP credentials type: %q", t) } } // createGCPCredentialsFromGoogleCredentialsEnv creates GCP credentials from GOOGLE_CREDENTIALS environment variable. // This can be either a file path or the JSON content directly (to mirror how Terraform works). func createGCPCredentialsFromGoogleCredentialsEnv(ctx context.Context, env map[string]string) (option.ClientOption, error) { var account = struct { PrivateKeyID string `json:"private_key_id"` PrivateKey string `json:"private_key"` ClientEmail string `json:"client_email"` ClientID string `json:"client_id"` }{} // to mirror how Terraform works, we have to accept either the file path or the contents creds := env["GOOGLE_CREDENTIALS"] contents, err := util.FileOrData(creds) if err != nil { return nil, errors.Errorf("Error loading credentials: %w", err) } if err := json.Unmarshal([]byte(contents), &account); err != nil { return nil, errors.Errorf("Error parsing GCP credentials.") } conf := jwt.Config{ Email: account.ClientEmail, PrivateKey: []byte(account.PrivateKey), // We need the FullControl scope to be able to add metadata such as labels Scopes: []string{storage.ScopeFullControl}, TokenURL: tokenURL, } return option.WithHTTPClient(conf.Client(ctx)), nil } ================================================ FILE: internal/gcphelper/config_test.go ================================================ //go:build gcp package gcphelper_test import ( "context" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/gcphelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateGcpConfigWithApplicationCredentialsEnv(t *testing.T) { t.Parallel() ctx := context.Background() // Create a temporary credentials file tmpDir := t.TempDir() credsFile := filepath.Join(tmpDir, "credentials.json") err := os.WriteFile(credsFile, []byte(`{"type":"service_account"}`), 0644) require.NoError(t, err) env := map[string]string{ "GOOGLE_APPLICATION_CREDENTIALS": credsFile, } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } func TestCreateGcpConfigWithOAuthAccessTokenEnv(t *testing.T) { t.Parallel() ctx := context.Background() env := map[string]string{ "GOOGLE_OAUTH_ACCESS_TOKEN": "test-oauth-token", } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } func TestCreateGcpConfigWithGoogleCredentialsEnv(t *testing.T) { t.Parallel() ctx := context.Background() // Test with JSON content directly (not a file path) credsJSON := `{ "type": "service_account", "project_id": "test-project", "private_key_id": "test-key-id", "private_key": "-----BEGIN PRIVATE KEY-----\nfake-private-key\n-----END PRIVATE KEY-----\n", "client_email": "test@test-project.iam.gserviceaccount.com", "client_id": "123456789", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token" }` env := map[string]string{ "GOOGLE_CREDENTIALS": credsJSON, } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } func TestCreateGcpConfigWithCredentialsFileFromConfig(t *testing.T) { t.Parallel() ctx := context.Background() // Create a temporary credentials file tmpDir := t.TempDir() credsFile := filepath.Join(tmpDir, "credentials.json") err := os.WriteFile(credsFile, []byte(`{"type":"service_account"}`), 0644) require.NoError(t, err) env := map[string]string{} gcpCfg := &gcphelper.GCPSessionConfig{ Credentials: credsFile, } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithSessionConfig(gcpCfg).WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } func TestCreateGcpConfigWithAccessTokenFromConfig(t *testing.T) { t.Parallel() ctx := context.Background() env := map[string]string{} gcpCfg := &gcphelper.GCPSessionConfig{ AccessToken: "test-access-token", } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithSessionConfig(gcpCfg).WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } func TestGcpConfigEnvVarsTakePrecedenceOverConfig(t *testing.T) { t.Parallel() ctx := context.Background() // Create temporary credentials files tmpDir := t.TempDir() envCredsFile := filepath.Join(tmpDir, "env-credentials.json") configCredsFile := filepath.Join(tmpDir, "config-credentials.json") err := os.WriteFile(envCredsFile, []byte(`{"type":"service_account"}`), 0644) require.NoError(t, err) err = os.WriteFile(configCredsFile, []byte(`{"type":"service_account"}`), 0644) require.NoError(t, err) // Set environment variable - this should take precedence over config env := map[string]string{ "GOOGLE_APPLICATION_CREDENTIALS": envCredsFile, } // Create config with explicit credentials - but env var should be used instead gcpCfg := &gcphelper.GCPSessionConfig{ Credentials: configCredsFile, // This should be ignored in favor of env var } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithSessionConfig(gcpCfg).WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) // In GCP, environment variables take precedence over config values // The if-else chain in CreateGcpConfig checks env vars first } func TestCreateGcpConfigWithImpersonation(t *testing.T) { t.Parallel() ctx := context.Background() env := map[string]string{} gcpCfg := &gcphelper.GCPSessionConfig{ ImpersonateServiceAccount: "test@project.iam.gserviceaccount.com", ImpersonateServiceAccountDelegates: []string{"delegate@project.iam.gserviceaccount.com"}, } // This will fail because we don't have real credentials, but we can verify // that the impersonation configuration is attempted _, err := gcphelper.NewGCPConfigBuilder().WithSessionConfig(gcpCfg).WithEnv(env).Build(ctx) // We expect an error because impersonation requires valid base credentials // The error should be about impersonation, not about missing credentials require.Error(t, err) assert.Contains(t, err.Error(), "impersonation") } func TestCreateGcpConfigWithNoCredentials(t *testing.T) { t.Parallel() ctx := context.Background() env := map[string]string{} // No credentials provided - should return empty options (will use default credentials) clientOpts, err := gcphelper.NewGCPConfigBuilder().WithEnv(env).Build(ctx) require.NoError(t, err) // Should return empty options when no credentials are provided // (default credentials will be used by GCP client) assert.Empty(t, clientOpts) } func TestCreateGcpConfigWithGoogleCredentialsFile(t *testing.T) { t.Parallel() ctx := context.Background() // Create a temporary credentials file tmpDir := t.TempDir() credsFile := filepath.Join(tmpDir, "credentials.json") credsJSON := `{ "type": "service_account", "project_id": "test-project", "private_key_id": "test-key-id", "private_key": "-----BEGIN PRIVATE KEY-----\nfake-private-key\n-----END PRIVATE KEY-----\n", "client_email": "test@test-project.iam.gserviceaccount.com", "client_id": "123456789" }` err := os.WriteFile(credsFile, []byte(credsJSON), 0644) require.NoError(t, err) // Test with GOOGLE_CREDENTIALS pointing to a file path env := map[string]string{ "GOOGLE_CREDENTIALS": credsFile, } clientOpts, err := gcphelper.NewGCPConfigBuilder().WithEnv(env).Build(ctx) require.NoError(t, err) assert.NotEmpty(t, clientOpts) } ================================================ FILE: internal/git/benchmark_test.go ================================================ package git_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/stretchr/testify/require" ) func BenchmarkGitOperations(b *testing.B) { // Setup a git repository for testing repoDir := b.TempDir() g, err := git.NewGitRunner() require.NoError(b, err) g = g.WithWorkDir(repoDir) ctx := b.Context() err = g.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", false, 1, "main") require.NoError(b, err) // This makes it so that the comparison isn't exactly apples to apples, but we're OK with giving the go-git library // any advantage it can get. err = g.GoOpenGitDir() require.NoError(b, err) b.Cleanup(func() { err = g.GoCloseStorage() if err != nil { b.Logf("Error closing storage: %s", err) } }) b.Run("ls-remote", func(b *testing.B) { for b.Loop() { _, err = g.LsRemote(ctx, "https://github.com/gruntwork-io/terragrunt.git", "HEAD") require.NoError(b, err) } }) b.Run("ls-tree -r", func(b *testing.B) { for b.Loop() { _, err = g.LsTreeRecursive(ctx, "HEAD") require.NoError(b, err) } }) b.Run("go-ls-tree -r", func(b *testing.B) { for b.Loop() { _, err = g.GoLsTreeRecursive("HEAD") require.NoError(b, err) } }) } ================================================ FILE: internal/git/diff.go ================================================ package git import ( "bufio" "bytes" "strings" ) const ( minDiffPartsLength = 2 ) // Diffs represents the diffs between two Git references. type Diffs struct { Added []string Removed []string Changed []string } // ParseDiff parses the stdout of a `git diff --name-status --no-renames` into a Diffs object. func ParseDiff(output []byte) (*Diffs, error) { maxCount := bytes.Count(output, []byte("\n")) + 1 added := make([]string, 0, maxCount) removed := make([]string, 0, maxCount) changed := make([]string, 0, maxCount) scanner := bufio.NewScanner(bytes.NewReader(output)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } parts := strings.Fields(line) if len(parts) < minDiffPartsLength { return nil, &WrappedError{ Op: "parse_diff", Context: "invalid diff line", Err: ErrParseDiff, } } status := parts[0] path := strings.Join(parts[1:], " ") // Handle paths with spaces switch status { case "A": added = append(added, path) case "D": removed = append(removed, path) case "M": changed = append(changed, path) } } if err := scanner.Err(); err != nil { return nil, &WrappedError{ Op: "parse_diff", Context: "failed to read diff output", Err: err, } } return &Diffs{ Added: added, Removed: removed, Changed: changed, }, nil } ================================================ FILE: internal/git/errors.go ================================================ package git import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" ) // Error types that can be returned by the cas package type Error string func (e Error) Error() string { return string(e) } const ( // ErrParseTree is returned when failing to parse git tree output ErrParseTree Error = "failed to parse git tree output" // ErrParseDiff is returned when failing to parse git diff output ErrParseDiff Error = "failed to parse git diff output" // ErrGitClone is returned when the git clone operation fails ErrGitClone Error = "failed to complete git clone" // ErrCreateTempDir is returned when failing to create a temporary directory ErrCreateTempDir Error = "failed to create temporary directory" // ErrCleanupTempDir is returned when failing to clean up a temporary directory ErrCleanupTempDir Error = "failed to clean up temporary directory" ) // WrappedError provides additional context for errors type WrappedError struct { Op string // Operation that failed Path string // File path if applicable Err error // Original error Context string // Additional context } func (e *WrappedError) Error() string { if e.Context != "" { return fmt.Sprintf("%s: %s: %v", e.Op, e.Context, e.Err) } return fmt.Sprintf("%s: %v", e.Op, e.Err) } func (e *WrappedError) Unwrap() error { return e.Err } // Git operation errors var ( ErrCommandSpawn = errors.New("failed to spawn git command") ErrNoMatchingReference = errors.New("no matching reference") ErrReadTree = errors.New("failed to read tree") ErrNoWorkDir = errors.New("working directory not set") ErrNoGoRepo = errors.New("go repository not set") ) ================================================ FILE: internal/git/git.go ================================================ // Package git provides support for Git operations needed throughout the Terragrunt codebase. // // The package primarily uses the `git` binary installed on the host system, but experimentally supports // the `go-git` library for some operations. As of yet, the performance of the `go-git` library is not // as good as the `git` binary, so we don't use it by default. If we can optimize usage of the `go-git` library // so that the performance difference is negligible, we can choose to use it instead of the `git` binary for certain // operations. // // Even assuming the performance differences are negligible, we'll still prefer to use the `git` binary for certain // operations. For example, operations related to remotes are likely easier to support with the `git` binary, as // users might have git configurations for authentication that would be inconvenient to port over to configuration // of the `go-git` library. This might change in the future. // // We'll prefix usage of the `go-git` library with "Go" to make it clear when we're using it. package git import ( "bytes" "context" "errors" "io" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/storage/filesystem" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( minGitPartsLength = 2 ) // GitRunner handles git command execution type GitRunner struct { goRepo *git.Repository goStorage *filesystem.Storage GitPath string WorkDir string } // NewGitRunner creates a new GitRunner instance func NewGitRunner() (*GitRunner, error) { gitPath, err := exec.LookPath("git") if err != nil { return nil, &WrappedError{ Op: "git", Context: "git not found", Err: ErrCommandSpawn, } } return &GitRunner{ GitPath: gitPath, }, nil } // WithWorkDir returns a new GitRunner with the specified working directory func (g *GitRunner) WithWorkDir(workDir string) *GitRunner { if g == nil { return &GitRunner{WorkDir: workDir} } newRunner := *g newRunner.WorkDir = workDir return &newRunner } // RequiresWorkDir returns an error if no working directory is set func (g *GitRunner) RequiresWorkDir() error { if g.WorkDir == "" { return &WrappedError{ Op: "git", Context: "no working directory set", Err: ErrNoWorkDir, } } return nil } // RequiresGoRepo returns an error if no go repository is set func (g *GitRunner) RequiresGoRepo() error { if g.goRepo == nil { return &WrappedError{ Op: "git", Context: "no go repository set", Err: ErrNoGoRepo, } } return nil } // GetRepoRoot returns the root directory of the git repository. func (g *GitRunner) GetRepoRoot(ctx context.Context) (string, error) { if err := g.RequiresWorkDir(); err != nil { return "", err } cmd := g.prepareCommand(ctx, "rev-parse", "--show-toplevel") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", &WrappedError{ Op: "git_rev_parse", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return strings.TrimSpace(stdout.String()), nil } // LsRemoteResult represents the output of git ls-remote type LsRemoteResult struct { Hash string Ref string } // LsRemote runs git ls-remote for a specific reference. // If ref is empty, we check HEAD instead. func (g *GitRunner) LsRemote(ctx context.Context, repo, ref string) ([]LsRemoteResult, error) { if ref == "" { ref = "HEAD" } args := []string{repo, ref} cmd := g.prepareCommand(ctx, "ls-remote", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, &WrappedError{ Op: "git_ls_remote", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } var results []LsRemoteResult lines := strings.SplitSeq(strings.TrimSpace(stdout.String()), "\n") for line := range lines { if line == "" { continue } parts := strings.Fields(line) if len(parts) >= minGitPartsLength { results = append(results, LsRemoteResult{ Hash: parts[0], Ref: parts[1], }) } } if len(results) == 0 { return nil, &WrappedError{ Op: "git_ls_remote", Context: "no matching references", Err: ErrNoMatchingReference, } } return results, nil } // Clone performs a git clone operation func (g *GitRunner) Clone(ctx context.Context, repo string, bare bool, depth int, branch string) error { if err := g.RequiresWorkDir(); err != nil { return err } args := []string{} if bare { args = append(args, "--bare") } if depth > 0 { args = append(args, "--depth", "1", "--single-branch") } if branch != "" { args = append(args, "--branch", branch) } args = append(args, repo, g.WorkDir) cmd := g.prepareCommand(ctx, "clone", args...) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_clone", Context: stderr.String(), Err: errors.Join(ErrGitClone, err), } } return nil } // CreateTempDir creates a new temporary directory for git operations func (g *GitRunner) CreateTempDir() (string, func() error, error) { prefix := "terragrunt-cas-" // Add a timestamp to the prefix to avoid conflicts prefix += strconv.FormatInt(time.Now().UnixNano(), 10) tempDir, err := os.MkdirTemp("", prefix+"*") if err != nil { return "", nil, &WrappedError{ Op: "create_temp_dir", Context: err.Error(), Err: ErrCreateTempDir, } } g.WorkDir = tempDir cleanup := func() error { if err := os.RemoveAll(tempDir); err != nil { return &WrappedError{ Op: "cleanup_temp_dir", Context: err.Error(), Err: ErrCleanupTempDir, } } return nil } return tempDir, cleanup, nil } // ExtractRepoName extracts the repository name from a git URL func ExtractRepoName(repo string) string { name := filepath.Base(repo) return strings.TrimSuffix(name, ".git") } // LsTreeRecursive runs git ls-tree -r and returns all blobs recursively // This eliminates the need for multiple separate ls-tree calls on subtrees func (g *GitRunner) LsTreeRecursive(ctx context.Context, ref string) (*Tree, error) { if err := g.RequiresWorkDir(); err != nil { return nil, err } // Use recursive ls-tree to get all blobs in a single command cmd := g.prepareCommand(ctx, "ls-tree", "-r", ref) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, &WrappedError{ Op: "git_ls_tree_recursive", Context: stderr.String(), Err: errors.Join(ErrReadTree, err), } } tree, err := ParseTree(stdout.Bytes(), ".") if err != nil { return nil, err } return tree, nil } // CatFile writes the contents of a git object // to a given writer. func (g *GitRunner) CatFile(ctx context.Context, hash string, out io.Writer) error { if err := g.RequiresWorkDir(); err != nil { return err } var stderr bytes.Buffer cmd := g.prepareCommand(ctx, "cat-file", "-p", hash) cmd.Stdout = out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_cat_file", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return nil } // CreateDetachedWorktree creates a new detached worktree for a given reference // as a given directory func (g *GitRunner) CreateDetachedWorktree(ctx context.Context, dir, ref string) error { if err := g.RequiresWorkDir(); err != nil { return err } cmd := g.prepareCommand(ctx, "worktree", "add", "--detach", dir, ref) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_create_detached_worktree", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return nil } // RemoveWorktree removes a Git worktree for a given path func (g *GitRunner) RemoveWorktree(ctx context.Context, path string) error { if err := g.RequiresWorkDir(); err != nil { return err } cmd := g.prepareCommand(ctx, "worktree", "remove", "--force", path) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_remove_worktree", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return nil } // Diff determines the diff between two Git references. func (g *GitRunner) Diff(ctx context.Context, fromRef, toRef string) (*Diffs, error) { if err := g.RequiresWorkDir(); err != nil { return nil, err } cmd := g.prepareCommand(ctx, "diff", "--name-status", "--no-renames", fromRef, toRef) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, &WrappedError{ Op: "git_diff", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return ParseDiff(stdout.Bytes()) } // Init initializes a Git repository func (g *GitRunner) Init(ctx context.Context) error { if err := g.RequiresWorkDir(); err != nil { return err } cmd := g.prepareCommand(ctx, "init") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_init", Context: stderr.String(), Err: errors.Join(ErrCommandSpawn, err), } } return nil } // HasUncommittedChanges checks if there are uncommitted changes in the working directory. // Returns true if there are uncommitted changes, false otherwise (including if git command fails or not in a git repo). func (g *GitRunner) HasUncommittedChanges(ctx context.Context) bool { cmd := g.prepareCommand(ctx, "status", "--porcelain") var stdout bytes.Buffer cmd.Stdout = &stdout // If git command fails (e.g., not in a git repo), return false if err := cmd.Run(); err != nil { return false } // Check if there are uncommitted changes (non-empty output) return strings.TrimSpace(stdout.String()) != "" } // Config gets the configuration of the Git repository func (g *GitRunner) Config(ctx context.Context, name string) (string, error) { if err := g.RequiresWorkDir(); err != nil { return "", err } cmd := g.prepareCommand(ctx, "config", name) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", &WrappedError{ Op: "git_config", Context: stderr.String(), Err: ErrCommandSpawn, } } return strings.TrimSpace(stdout.String()), nil } // GetRemoteURL returns the origin remote URL, or empty string on error. func (g *GitRunner) GetRemoteURL(ctx context.Context) string { remote, _ := g.Config(ctx, "remote.origin.url") return remote } // GetCurrentBranch returns the current branch name, or empty string on error. func (g *GitRunner) GetCurrentBranch(ctx context.Context) string { if err := g.RequiresWorkDir(); err != nil { return "" } cmd := g.prepareCommand(ctx, "rev-parse", "--abbrev-ref", "HEAD") var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return "" } return strings.TrimSpace(stdout.String()) } // GetHeadCommit returns the current HEAD commit hash, or empty string on error. func (g *GitRunner) GetHeadCommit(ctx context.Context) string { if err := g.RequiresWorkDir(); err != nil { return "" } cmd := g.prepareCommand(ctx, "rev-parse", "HEAD") var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return "" } return strings.TrimSpace(stdout.String()) } // GetDefaultBranch implements the hybrid approach to detect the default branch: // 1. Tries to determine the default branch of the remote repository using the fast local method first // 2. Falls back to the network method if the local method fails // 3. Attempts to update local cache for future use // Returns the branch name (e.g., "main") or an error if both methods fail. func (g *GitRunner) GetDefaultBranch(ctx context.Context, l log.Logger) string { branch, err := g.GetDefaultBranchLocal(ctx) if err == nil && branch != "" { return branch } branch, err = g.GetDefaultBranchRemote(ctx) if err == nil && branch != "" { err = g.SetRemoteHeadAuto(ctx) if err != nil { l.Warnf("Failed to update local cache for default branch: %v", err) } return branch } l.Debugf("Failed to determine default branch of remote repository, attempting to get default branch of local repository") if b, err := g.Config(ctx, "init.defaultBranch"); err == nil && b != "" { return b } l.Debugf("Failed to determine default branch of local repository, using 'main' as fallback") return "main" } // GetDefaultBranchLocal attempts to get the default branch using the local cached remote HEAD. // Returns the branch name (e.g., "main") if successful, or an error if the local ref is not set. // This is fast and works offline, but requires that `git remote set-head origin --auto` has been run. func (g *GitRunner) GetDefaultBranchLocal(ctx context.Context) (string, error) { if err := g.RequiresWorkDir(); err != nil { return "", err } cmd := g.prepareCommand(ctx, "rev-parse", "--abbrev-ref", "origin/HEAD") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", &WrappedError{ Op: "git_rev_parse_origin_head", Context: stderr.String(), Err: ErrCommandSpawn, } } result := strings.TrimSpace(stdout.String()) // If the result is just "origin/HEAD", the local ref is not properly set if result == "origin/HEAD" { return "", &WrappedError{ Op: "git_rev_parse_origin_head", Context: "local origin/HEAD ref not set", Err: ErrNoMatchingReference, } } if after, ok := strings.CutPrefix(result, "origin/"); ok { return after, nil } return result, nil } // GetDefaultBranchRemote queries the remote repository to determine the default branch. // This is the most accurate method but requires network access. // Returns the branch name (e.g., "main") if successful. func (g *GitRunner) GetDefaultBranchRemote(ctx context.Context) (string, error) { if err := g.RequiresWorkDir(); err != nil { return "", err } cmd := g.prepareCommand(ctx, "ls-remote", "--symref", "origin", "HEAD") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", &WrappedError{ Op: "git_ls_remote_symref", Context: stderr.String(), Err: ErrCommandSpawn, } } // Parse output: "ref: refs/heads/main HEAD" output := stdout.String() lines := strings.SplitSeq(strings.TrimSpace(output), "\n") for line := range lines { if line == "" { continue } if strings.HasPrefix(line, "ref:") { parts := strings.Fields(line) if len(parts) >= 2 { //nolint:mnd ref := parts[1] if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { return after, nil } } } } return "", &WrappedError{ Op: "git_ls_remote_symref", Context: "could not parse default branch from ls-remote output", Err: ErrNoMatchingReference, } } // SetRemoteHeadAuto runs `git remote set-head origin --auto` to update the local cached remote HEAD. // This makes future calls to GetDefaultBranchLocal faster. func (g *GitRunner) SetRemoteHeadAuto(ctx context.Context) error { if err := g.RequiresWorkDir(); err != nil { return err } cmd := g.prepareCommand(ctx, "remote", "set-head", "origin", "--auto") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return &WrappedError{ Op: "git_remote_set_head", Context: stderr.String(), Err: ErrCommandSpawn, } } return nil } func (g *GitRunner) prepareCommand(ctx context.Context, name string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, g.GitPath, append([]string{name}, args...)...) cmd.Cancel = func() error { if cmd.Process == nil { return nil } if sig := signal.SignalFromContext(ctx); sig != nil { return cmd.Process.Signal(sig) } return cmd.Process.Signal(os.Kill) } if g.WorkDir != "" { cmd.Dir = g.WorkDir } return cmd } ================================================ FILE: internal/git/git_test.go ================================================ package git_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGitRunner_LsRemote(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) ctx := t.Context() t.Run("valid repository", func(t *testing.T) { t.Parallel() results, err := runner.LsRemote(ctx, "https://github.com/gruntwork-io/terragrunt.git", "HEAD") require.NoError(t, err) require.NotEmpty(t, results) assert.Regexp(t, "^[0-9a-f]{40}$", results[0].Hash) assert.Equal(t, "HEAD", results[0].Ref) }) t.Run("invalid repository", func(t *testing.T) { t.Parallel() _, err := runner.LsRemote(ctx, "https://github.com/nonexistent/repo.git", "HEAD") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrCommandSpawn) }) t.Run("nonexistent reference", func(t *testing.T) { t.Parallel() _, err := runner.LsRemote(ctx, "https://github.com/gruntwork-io/terragrunt.git", "nonexistent-branch") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoMatchingReference) }) } func TestGitRunner_Clone(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("shallow clone", func(t *testing.T) { t.Parallel() cloneDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(cloneDir) err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", true, 1, "main") require.NoError(t, err) // Verify it's a git repository _, err = os.Stat(filepath.Join(cloneDir, "HEAD")) require.NoError(t, err) }) t.Run("clone without workdir fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", true, 1, "main") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoWorkDir) }) t.Run("invalid repository", func(t *testing.T) { t.Parallel() cloneDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(cloneDir) err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt-fake.git", false, 1, "") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrGitClone) }) } func TestCreateTempDir(t *testing.T) { t.Parallel() gitRunner, err := git.NewGitRunner() require.NoError(t, err) dir, cleanup, err := gitRunner.CreateTempDir() require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, cleanup()) }) // Verify directory exists _, err = os.Stat(dir) require.NoError(t, err) // Verify it's empty entries, err := os.ReadDir(dir) require.NoError(t, err) assert.Empty(t, entries) } func TestExtractRepoName(t *testing.T) { t.Parallel() tests := []struct { name string repo string want string }{ { name: "simple repo", repo: "https://github.com/user/repo.git", want: "repo", }, { name: "no .git suffix", repo: "https://github.com/user/repo", want: "repo", }, { name: "with path", repo: "/path/to/repo.git", want: "repo", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.want, git.ExtractRepoName(tt.repo)) }) } } func TestGitRunner_LsTree(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("valid repository", func(t *testing.T) { t.Parallel() cloneDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(cloneDir) // First clone a repository err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", true, 1, "main") require.NoError(t, err) // Then try to ls-tree HEAD tree, err := runner.LsTreeRecursive(ctx, "HEAD") require.NoError(t, err) require.NotEmpty(t, tree) }) t.Run("ls-tree without workdir fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) _, err = runner.LsTreeRecursive(ctx, "HEAD") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoWorkDir) }) t.Run("invalid reference", func(t *testing.T) { t.Parallel() cloneDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(cloneDir) // First clone a repository err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", true, 1, "main") require.NoError(t, err) // Try to ls-tree an invalid reference _, err = runner.LsTreeRecursive(ctx, "nonexistent") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrReadTree) }) t.Run("invalid repository", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(helpers.TmpDirWOSymlinks(t)) // Try to ls-tree in an empty directory _, err = runner.LsTreeRecursive(ctx, "HEAD") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrReadTree) }) } func TestGitRunner_RequiresWorkDir(t *testing.T) { t.Parallel() t.Run("with workdir", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(helpers.TmpDirWOSymlinks(t)) err = runner.RequiresWorkDir() assert.NoError(t, err) }) t.Run("without workdir", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) err = runner.RequiresWorkDir() require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoWorkDir) }) } ================================================ FILE: internal/git/gogit.go ================================================ package git import ( "path/filepath" "github.com/go-git/go-billy/v6/osfs" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/cache" "github.com/go-git/go-git/v6/plumbing/object" "github.com/go-git/go-git/v6/storage/filesystem" "github.com/gruntwork-io/terragrunt/internal/errors" ) // GoOpenGitDir opens a Git repository using the `go-git` library, but chroots to the `.git` directory if present. // // Use this for operations that don't need access to the rest of the repository for read-only access, etc. // // Opening a Git repository leaves the storage open, so it's the responsibility of the caller to // close the storage with `GoCloseStorage` when it is no longer needed. func (g *GitRunner) GoOpenGitDir() error { if err := g.RequiresWorkDir(); err != nil { return err } baseDir := g.WorkDir fs := osfs.New(baseDir) if _, err := fs.Stat(git.GitDirName); err == nil { fs, err = fs.Chroot(git.GitDirName) if err != nil { return err } } s := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true}) repo, err := git.Open(s, fs) if err != nil { return err } g.goRepo = repo g.goStorage = s return nil } // GoOpenRepo opens a Git repository using the `go-git` library. func (g *GitRunner) GoOpenRepo() error { if err := g.RequiresWorkDir(); err != nil { return err } baseDir := g.WorkDir wt := osfs.New(baseDir) dotGitDir := osfs.New(baseDir) if _, err := dotGitDir.Stat(git.GitDirName); err == nil { dotGitDir, err = dotGitDir.Chroot(git.GitDirName) if err != nil { return err } } s := filesystem.NewStorageWithOptions(dotGitDir, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true}) repo, err := git.Open(s, wt) if err != nil { return err } g.goRepo = repo g.goStorage = s return nil } // GoCloseStorage closes the storage for a Git repository. func (g *GitRunner) GoCloseStorage() error { if g.goStorage == nil { return nil } if err := g.goStorage.Close(); err != nil { return err } g.goRepo = nil g.goStorage = nil return nil } // GoLsTreeRecursive uses the `go-git` library to recursively list the contents of a git tree. // // In testing, this is significantly slower than LsTreeRecursive, so we don't use it right now. // We'll keep it here and benchmark it again later if we can optimize it. func (g *GitRunner) GoLsTreeRecursive(ref string) ([]TreeEntry, error) { if err := g.RequiresGoRepo(); err != nil { return nil, err } h, err := g.goRepo.ResolveRevision(plumbing.Revision(ref)) if err != nil { return nil, err } c, err := g.goRepo.CommitObject(*h) if err != nil { return nil, err } tree, err := c.Tree() if err != nil { return nil, err } entries, err := g.goLsTreeOnTree(tree, "") if err != nil { return nil, err } return entries, nil } // GoAdd adds a file to the Git repository. func (g *GitRunner) GoAdd(paths ...string) error { if err := g.RequiresGoRepo(); err != nil { return err } w, err := g.goRepo.Worktree() if err != nil { return err } for _, path := range paths { _, err := w.Add(path) if err != nil { return err } } return nil } // GoStatus gets the status of the Git repository. func (g *GitRunner) GoStatus() (git.Status, error) { if err := g.RequiresGoRepo(); err != nil { return nil, err } w, err := g.goRepo.Worktree() if err != nil { return nil, err } return w.Status() } // GoCommit commits changes to the Git repository. func (g *GitRunner) GoCommit(message string, opts *git.CommitOptions) error { if err := g.RequiresGoRepo(); err != nil { return err } if opts == nil { return errors.New("commit options are required for go commits") } w, err := g.goRepo.Worktree() if err != nil { return err } _, err = w.Commit(message, opts) if err != nil { return err } return nil } // GoCheckout checks out a branch in the Git repository. func (g *GitRunner) GoCheckout(opts *git.CheckoutOptions) error { if err := g.RequiresGoRepo(); err != nil { return err } if opts == nil { return errors.New("checkout options are required for go checkouts") } w, err := g.goRepo.Worktree() if err != nil { return err } err = w.Checkout(opts) if err != nil { return err } return nil } // GoOpenRepoHead gets the head of the Git repository. func (g *GitRunner) GoOpenRepoHead() (*plumbing.Reference, error) { if err := g.RequiresGoRepo(); err != nil { return nil, err } return g.goRepo.Head() } // GoOpenRepoCommitObject gets a commit object from the Git repository. func (g *GitRunner) GoOpenRepoCommitObject(hash plumbing.Hash) (*object.Commit, error) { if err := g.RequiresGoRepo(); err != nil { return nil, err } return g.goRepo.CommitObject(hash) } // goLsTreeOnTree uses the `go-git` library to recursively list the contents of a git tree. func (g *GitRunner) goLsTreeOnTree(tree *object.Tree, path string) ([]TreeEntry, error) { entries := make([]TreeEntry, 0, len(tree.Entries)) for _, entry := range tree.Entries { var entryPath string if path == "" { entryPath = entry.Name } else { entryPath = filepath.Join(path, entry.Name) } if entry.Mode.IsFile() { entries = append(entries, TreeEntry{ Mode: entry.Mode.String(), Type: "blob", Hash: entry.Hash.String(), Path: entryPath, }) } else { mode, err := entry.Mode.ToOSFileMode() if err != nil { return nil, err } if mode.IsDir() { subTree, err := tree.Tree(entry.Name) if err != nil { return nil, err } subTreeEntries, err := g.goLsTreeOnTree(subTree, entryPath) if err != nil { return nil, err } entries = append(entries, subTreeEntries...) } } } return entries, nil } ================================================ FILE: internal/git/gogit_test.go ================================================ package git_test import ( "os" "path/filepath" "testing" "time" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGitRunner_GoLsTreeRecursive(t *testing.T) { t.Parallel() ctx := t.Context() runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(helpers.TmpDirWOSymlinks(t)) err = runner.Clone(ctx, "https://github.com/gruntwork-io/terragrunt.git", true, 1, "main") require.NoError(t, err) err = runner.GoOpenGitDir() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) tree, err := runner.GoLsTreeRecursive("HEAD") require.NoError(t, err) require.NotEmpty(t, tree) } func TestGitRunner_GoAdd(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("add single file", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) // Create a new file testFile := filepath.Join(workDir, "test-file.txt") err = os.WriteFile(testFile, []byte("test content"), 0644) require.NoError(t, err) // Add the file err = runner.GoAdd("test-file.txt") require.NoError(t, err) // Verify file is staged by checking worktree status s, err := runner.GoStatus() require.NoError(t, err) fileStatus, ok := s["test-file.txt"] require.True(t, ok, "test-file.txt should be in status") assert.Equal(t, gogit.Added, fileStatus.Staging) }) t.Run("add multiple files", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) // Create multiple files testFile1 := filepath.Join(workDir, "test-file-1.txt") testFile2 := filepath.Join(workDir, "test-file-2.txt") err = os.WriteFile(testFile1, []byte("test content 1"), 0644) require.NoError(t, err) err = os.WriteFile(testFile2, []byte("test content 2"), 0644) require.NoError(t, err) // Add both files err = runner.GoAdd("test-file-1.txt", "test-file-2.txt") require.NoError(t, err) // Verify both files are staged s, err := runner.GoStatus() require.NoError(t, err) fileStatus1, ok := s["test-file-1.txt"] require.True(t, ok, "test-file-1.txt should be in status") assert.Equal(t, gogit.Added, fileStatus1.Staging) fileStatus2, ok := s["test-file-2.txt"] require.True(t, ok, "test-file-2.txt should be in status") assert.Equal(t, gogit.Added, fileStatus2.Staging) }) t.Run("add without open repo fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(helpers.TmpDirWOSymlinks(t)) err = runner.GoAdd("test-file.txt") require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoGoRepo) }) t.Run("add nonexistent file fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) err = runner.GoAdd("nonexistent-file.txt") require.Error(t, err) }) } func TestGitRunner_GoCommit(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("commit staged changes", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) // Create and add a file testFile := filepath.Join(workDir, "test-file.txt") err = os.WriteFile(testFile, []byte("test content"), 0644) require.NoError(t, err) err = runner.GoAdd("test-file.txt") require.NoError(t, err) // Commit the changes commitMessage := "test commit" err = runner.GoCommit(commitMessage, &gogit.CommitOptions{ Author: &object.Signature{ Name: "Test Author", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) // Verify commit was created head, err := runner.GoOpenRepoHead() require.NoError(t, err) commit, err := runner.GoOpenRepoCommitObject(head.Hash()) require.NoError(t, err) assert.Equal(t, commitMessage, commit.Message) }) t.Run("commit with options", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) // Create and add a file testFile := filepath.Join(workDir, "test-file.txt") err = os.WriteFile(testFile, []byte("test content"), 0644) require.NoError(t, err) err = runner.GoAdd("test-file.txt") require.NoError(t, err) // Commit with options commitMessage := "test commit with options" opts := &gogit.CommitOptions{ Author: &object.Signature{ Name: "Test Author", Email: "test@example.com", }, } err = runner.GoCommit(commitMessage, opts) require.NoError(t, err) // Verify commit was created with correct author head, err := runner.GoOpenRepoHead() require.NoError(t, err) commit, err := runner.GoOpenRepoCommitObject(head.Hash()) require.NoError(t, err) assert.Equal(t, commitMessage, commit.Message) assert.Equal(t, "Test Author", commit.Author.Name) assert.Equal(t, "test@example.com", commit.Author.Email) }) t.Run("commit without open repo fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(helpers.TmpDirWOSymlinks(t)) err = runner.GoCommit("test commit", nil) require.Error(t, err) var wrappedErr *git.WrappedError require.ErrorAs(t, err, &wrappedErr) assert.ErrorIs(t, wrappedErr.Err, git.ErrNoGoRepo) }) t.Run("commit without staged changes fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) // Try to commit without options err = runner.GoCommit("test commit", &gogit.CommitOptions{ Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.Error(t, err) assert.ErrorContains(t, err, "cannot create empty commit: clean working tree") }) t.Run("commit without options fails", func(t *testing.T) { t.Parallel() runner, err := git.NewGitRunner() require.NoError(t, err) workDir := helpers.TmpDirWOSymlinks(t) runner = runner.WithWorkDir(workDir) err = runner.Init(ctx) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) testFile := filepath.Join(workDir, "test-file.txt") err = os.WriteFile(testFile, []byte("test content"), 0644) require.NoError(t, err) err = runner.GoAdd("test-file.txt") require.NoError(t, err) // Try to commit without options err = runner.GoCommit("test commit", nil) require.Error(t, err) }) } ================================================ FILE: internal/git/tree.go ================================================ package git import ( "bufio" "bytes" "fmt" "io" "strings" ) const ( // The minimum number of parts in the stdout of the `git ls-tree` command minTreePartsLength = 4 ) // Tree represents a git tree object with its entries type Tree struct { entries []TreeEntry path string data []byte } // TreeEntry represents a single entry in a git tree type TreeEntry struct { Mode string Type string Hash string Path string } // Write writes a tree to a given writer func (t *Tree) Write(w io.Writer) error { for _, entry := range t.entries { _, err := fmt.Fprintf(w, "%s %s %s\t%s\n", entry.Mode, entry.Type, entry.Hash, entry.Path) if err != nil { return err } } return nil } // Entries returns the tree entries func (t *Tree) Entries() []TreeEntry { return t.entries } // Path returns the tree path func (t *Tree) Path() string { return t.path } // Data returns the tree data func (t *Tree) Data() []byte { return t.data } // ParseTree parses the stdout of git ls-tree [-r] into a Tree object. func ParseTree(output []byte, path string) (*Tree, error) { entries := make([]TreeEntry, 0, bytes.Count(output, []byte("\n"))+1) scanner := bufio.NewScanner(bytes.NewReader(output)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } entry, err := ParseTreeEntry(line) if err != nil { return nil, err } entries = append(entries, entry) } if err := scanner.Err(); err != nil { return nil, &WrappedError{ Op: "parse_tree", Context: "failed to read tree output", Err: err, } } return &Tree{ entries: entries, path: path, data: output, }, nil } // ParseTreeEntry parses a single line from git ls-tree output func ParseTreeEntry(line string) (TreeEntry, error) { // Format: parts := strings.Fields(line) if len(parts) < minTreePartsLength { return TreeEntry{}, &WrappedError{ Op: "parse_tree_entry", Context: "invalid tree entry format", Err: ErrParseTree, } } return TreeEntry{ Mode: parts[0], Type: parts[1], Hash: parts[2], Path: strings.Join(parts[3:], " "), // Handle paths with spaces }, nil } ================================================ FILE: internal/github/client.go ================================================ // Package github provides clients for interacting with the GitHub API and downloading GitHub releases. package github import ( "context" "encoding/json" "fmt" "io" "maps" "net/http" "os" "path/filepath" "strings" "testing" "time" "github.com/hashicorp/go-getter" "golang.org/x/sync/errgroup" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) // GitHubAPIClient represents a GitHub API client. type GitHubAPIClient struct { baseURL string httpClient *http.Client cache *cache.ExpiringCache[string] defaultHeaders http.Header } // Release represents a GitHub repository release. type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` URL string `json:"html_url"` } // GitHubAPIClientOption is a function that configures a GitHubAPIClient. type GitHubAPIClientOption func(*GitHubAPIClient) // WithHTTPClient sets the HTTP client for the GitHub client. func WithHTTPClient(httpClient *http.Client) GitHubAPIClientOption { return func(c *GitHubAPIClient) { c.httpClient = httpClient } } // WithBaseURL sets the base URL for the GitHub API. func WithBaseURL(baseURL string) GitHubAPIClientOption { return func(c *GitHubAPIClient) { c.baseURL = baseURL } } // WithGithubToken sets the GitHub token for authentication. func WithGithubToken(token string) GitHubAPIClientOption { return func(c *GitHubAPIClient) { c.defaultHeaders.Set("Authorization", "Bearer "+token) } } // WithGithubComDefaultAuth sets the authentication header based on the assumption // we're talking to github.com, and using the same logic as the gh cli: // https://cli.github.com/manual/gh_help_environment func WithGithubComDefaultAuth() GitHubAPIClientOption { return func(c *GitHubAPIClient) { if tok := getGithubTokenFromEnv(); tok != "" { c.defaultHeaders.Set("Authorization", "Bearer "+tok) } } } // getGithubTokenFromEnv retrieves the GitHub token from environment // variables using the same logic as the gh cli: // https://cli.github.com/manual/gh_help_environment func getGithubTokenFromEnv() string { if tok := os.Getenv("GH_TOKEN"); tok != "" { return tok } if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { return tok } return "" } // NewGitHubAPIClient creates a new GitHub API client with optional configuration. func NewGitHubAPIClient(opts ...GitHubAPIClientOption) *GitHubAPIClient { client := &GitHubAPIClient{ baseURL: "https://api.github.com", httpClient: &http.Client{Timeout: 30 * time.Second}, cache: cache.NewExpiringCache[string]("github_api"), defaultHeaders: http.Header{}, } client.defaultHeaders.Set("X-GitHub-Api-Version", "2022-11-28") for _, opt := range opts { opt(client) } return client } // setDefaultHeaders sets default headers for the given request func (c *GitHubAPIClient) setDefaultHeaders(req *http.Request) { req.Header = c.defaultHeaders.Clone() } // GetLatestRelease fetches the latest release for a given repository. // The repository should be in the format "owner/repo". func (c *GitHubAPIClient) GetLatestRelease(ctx context.Context, repository string) (*Release, error) { if repository == "" { return nil, errors.Errorf("repository cannot be empty") } parts := strings.Split(repository, "/") if len(parts) != 2 { return nil, errors.Errorf("repository must be in format 'owner/repo', got: %s", repository) } url := fmt.Sprintf("%s/repos/%s/releases/latest", c.baseURL, repository) if cachedTag, found := c.cache.Get(ctx, url); found { return &Release{TagName: cachedTag}, nil } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, errors.Errorf("failed to create HTTP request: %w", err) } c.setDefaultHeaders(req) req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := c.httpClient.Do(req) if err != nil { return nil, errors.Errorf("GitHub API request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, errors.Errorf( "GitHub API request to determine latest release failed with status %d: %s", resp.StatusCode, resp.Status, ) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.Errorf("failed to read response body: %w", err) } var release Release if err := json.Unmarshal(body, &release); err != nil { return nil, errors.Errorf("failed to parse GitHub API response: %w", err) } if release.TagName == "" { return nil, errors.Errorf("GitHub API returned empty tag name for latest release") } c.cache.Put(ctx, url, release.TagName, time.Now().Add(5*time.Minute)) return &release, nil } // GetLatestReleaseTag is a convenience method that returns just the tag name // of the latest release for a repository. func (c *GitHubAPIClient) GetLatestReleaseTag(ctx context.Context, repository string) (string, error) { release, err := c.GetLatestRelease(ctx, repository) if err != nil { return "", err } return release.TagName, nil } // GitHubReleasesDownloadClient represents a client for downloading GitHub release assets. type GitHubReleasesDownloadClient struct { logger log.Logger } // ReleaseAssets represents the assets to download for a GitHub release. type ReleaseAssets struct { Repository string Version string PackageFile string ChecksumFile string ChecksumSigFile string } // DownloadResult represents the result of downloading release assets. type DownloadResult struct { PackageFile string ChecksumFile string ChecksumSigFile string } // GitHubReleasesDownloadClientOption is a function that configures a GitHubReleasesDownloadClient. type GitHubReleasesDownloadClientOption func(*GitHubReleasesDownloadClient) // WithLogger sets the logger for the download client. func WithLogger(logger log.Logger) GitHubReleasesDownloadClientOption { return func(c *GitHubReleasesDownloadClient) { c.logger = logger } } // NewGitHubReleasesDownloadClient creates a new GitHub releases download client. func NewGitHubReleasesDownloadClient(opts ...GitHubReleasesDownloadClientOption) *GitHubReleasesDownloadClient { client := &GitHubReleasesDownloadClient{} for _, opt := range opts { opt(client) } return client } // DownloadReleaseAssets downloads the specified release assets from a GitHub repository. // It supports downloading from either full URLs (when repository contains "://") or // from GitHub releases using the standard GitHub releases URL format. func (c *GitHubReleasesDownloadClient) DownloadReleaseAssets( ctx context.Context, assets *ReleaseAssets, ) (*DownloadResult, error) { if assets.Repository == "" { return nil, errors.Errorf("repository cannot be empty") } if assets.PackageFile == "" { return nil, errors.Errorf("package file path cannot be empty") } result := &DownloadResult{ PackageFile: assets.PackageFile, } expectedLen := 1 if assets.ChecksumFile != "" { expectedLen++ } if assets.ChecksumSigFile != "" { expectedLen++ } downloads := make(map[string]string, expectedLen) if strings.Contains(assets.Repository, "://") { // If repository contains "://", treat it as a direct URL downloads[assets.Repository] = assets.PackageFile } else { if assets.Version == "" { return nil, errors.Errorf("version cannot be empty for GitHub repository downloads") } baseURL := fmt.Sprintf("https://%s/releases/download/%s", assets.Repository, assets.Version) packageFileName := filepath.Base(assets.PackageFile) downloads[fmt.Sprintf("%s/%s", baseURL, packageFileName)] = assets.PackageFile if assets.ChecksumFile != "" { checksumFileName := filepath.Base(assets.ChecksumFile) downloads[fmt.Sprintf("%s/%s", baseURL, checksumFileName)] = assets.ChecksumFile result.ChecksumFile = assets.ChecksumFile } if assets.ChecksumSigFile != "" { checksumSigFileName := filepath.Base(assets.ChecksumSigFile) downloads[fmt.Sprintf("%s/%s", baseURL, checksumSigFileName)] = assets.ChecksumSigFile result.ChecksumSigFile = assets.ChecksumSigFile } } g, downloadCtx := errgroup.WithContext(ctx) for url, localPath := range downloads { g.Go(func() error { if c.logger != nil { c.logger.Infof("Downloading %s to %s", url, localPath) } client := &getter.Client{ Ctx: downloadCtx, Src: url, Dst: localPath, Mode: getter.ClientModeFile, Decompressors: map[string]getter.Decompressor{}, } // Add GitHub token to HTTP headers if available if tok := getGithubTokenFromEnv(); tok != "" { // use the default getters client.Getters = maps.Clone(getter.Getters) // but override the https getter to inject the github token client.Getters["https"] = &getter.HttpGetter{ Netrc: true, Header: http.Header{ "Authorization": {"Bearer " + tok}, }, } // test servers don't use https, but we don't usually want to send auth tokens unencrypted if testing.Testing() { client.Getters["http"] = &getter.HttpGetter{ Netrc: true, Header: http.Header{ "Authorization": {"Bearer " + tok}, }, } } } if err := client.Get(); err != nil { return errors.Errorf("failed to download %s: %w", url, err) } return nil }) } if err := g.Wait(); err != nil { return nil, err } return result, nil } ================================================ FILE: internal/github/client_test.go ================================================ package github_test import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/github" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewClient(t *testing.T) { t.Parallel() client := github.NewGitHubAPIClient() require.NotNil(t, client) assert.NotNil(t, client) } func TestGithubAuthPickupOrder(t *testing.T) { t.Run("prefer GH_TOKEN", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer goodtoken", r.Header.Get("Authorization")) w.Header().Set("Content-Type", "application/json") response := `{ "tag_name": "v1.2.3", "name": "Release v1.2.3", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.3" }` _, err := fmt.Fprint(w, response) require.NoError(t, err) })) defer server.Close() t.Setenv("GH_TOKEN", "goodtoken") t.Setenv("GITHUB_TOKEN", "badtoken") client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL), github.WithGithubComDefaultAuth()) _, err := client.GetLatestRelease(t.Context(), "owner/repo") require.NoError(t, err) }) t.Run("use GITHUB_TOKEN", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer goodtoken", r.Header.Get("Authorization")) w.Header().Set("Content-Type", "application/json") response := `{ "tag_name": "v1.2.3", "name": "Release v1.2.3", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.3" }` _, err := fmt.Fprint(w, response) require.NoError(t, err) })) defer server.Close() t.Setenv("GH_TOKEN", "") t.Setenv("GITHUB_TOKEN", "goodtoken") client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL), github.WithGithubComDefaultAuth()) _, err := client.GetLatestRelease(t.Context(), "owner/repo") require.NoError(t, err) }) } func TestNewClientWithOptions(t *testing.T) { t.Parallel() customHTTPClient := &http.Client{Timeout: 10 * time.Second} customBaseURL := "https://custom.github.com" client := github.NewGitHubAPIClient( github.WithHTTPClient(customHTTPClient), github.WithBaseURL(customBaseURL), ) assert.NotNil(t, client) } func TestGetLatestRelease(t *testing.T) { t.Parallel() // Create a mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/repos/owner/repo/releases/latest", r.URL.Path) assert.Equal(t, "application/vnd.github.v3+json", r.Header.Get("Accept")) w.Header().Set("Content-Type", "application/json") response := `{ "tag_name": "v1.2.3", "name": "Release v1.2.3", "html_url": "https://github.com/owner/repo/releases/tag/v1.2.3" }` _, err := fmt.Fprint(w, response) assert.NoError(t, err) })) defer server.Close() client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL)) release, err := client.GetLatestRelease(t.Context(), "owner/repo") require.NoError(t, err) assert.Equal(t, "v1.2.3", release.TagName) assert.Equal(t, "Release v1.2.3", release.Name) assert.Equal(t, "https://github.com/owner/repo/releases/tag/v1.2.3", release.URL) } func TestGetLatestReleaseTag(t *testing.T) { t.Parallel() // Create a mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") response := `{"tag_name": "v2.0.0"}` _, err := fmt.Fprint(w, response) assert.NoError(t, err) })) defer server.Close() client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL)) tag, err := client.GetLatestReleaseTag(t.Context(), "owner/repo") require.NoError(t, err) assert.Equal(t, "v2.0.0", tag) } func TestGetLatestReleaseInvalidRepository(t *testing.T) { t.Parallel() client := github.NewGitHubAPIClient() testCases := []string{ "", "invalid", "too/many/parts", } for _, repo := range testCases { t.Run(fmt.Sprintf("repo=%s", repo), func(tt *testing.T) { _, err := client.GetLatestRelease(tt.Context(), repo) require.Error(t, err) }) } } func TestGetLatestReleaseHTTPError(t *testing.T) { t.Parallel() // Create a mock server that returns 404 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, err := fmt.Fprint(w, "Not Found") require.NoError(t, err) })) defer server.Close() client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL)) _, err := client.GetLatestRelease(t.Context(), "owner/repo") require.Error(t, err) assert.ErrorContains(t, err, "GitHub API request to determine latest release failed with status 404") } func TestGetLatestReleaseEmptyTag(t *testing.T) { t.Parallel() // Create a mock server that returns empty tag server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") response := `{"tag_name": ""}` _, err := fmt.Fprint(w, response) require.NoError(t, err) })) defer server.Close() client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL)) _, err := client.GetLatestRelease(t.Context(), "owner/repo") require.Error(t, err) assert.ErrorContains(t, err, "GitHub API returned empty tag name for latest release") } func TestGetLatestReleaseCaching(t *testing.T) { t.Parallel() callCount := 0 // Create a mock server that tracks how many times it's called server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") response := `{"tag_name": "v1.0.0"}` _, err := fmt.Fprint(w, response) require.NoError(t, err) })) defer server.Close() client := github.NewGitHubAPIClient(github.WithBaseURL(server.URL)) // First call should hit the server tag1, err := client.GetLatestReleaseTag(t.Context(), "owner/repo") require.NoError(t, err) assert.Equal(t, "v1.0.0", tag1) assert.Equal(t, 1, callCount) // Second call should use cache tag2, err := client.GetLatestReleaseTag(t.Context(), "owner/repo") require.NoError(t, err) assert.Equal(t, "v1.0.0", tag2) assert.Equal(t, 1, callCount) } // Tests for GitHubReleasesDownloadClient func TestNewGitHubReleasesDownloadClient(t *testing.T) { t.Parallel() client := github.NewGitHubReleasesDownloadClient() require.NotNil(t, client) } func TestNewGitHubReleasesDownloadClientWithOptions(t *testing.T) { t.Parallel() logger := log.New() client := github.NewGitHubReleasesDownloadClient(github.WithLogger(logger)) require.NotNil(t, client) } func TestDownloadReleaseAssetsValidation(t *testing.T) { t.Parallel() client := github.NewGitHubReleasesDownloadClient() ctx := context.Background() testCases := []struct { name string assets *github.ReleaseAssets errorMsg string }{ { name: "empty repository", assets: &github.ReleaseAssets{Repository: "", PackageFile: "/tmp/package.zip"}, errorMsg: "repository cannot be empty", }, { name: "empty package file", assets: &github.ReleaseAssets{Repository: "owner/repo", PackageFile: ""}, errorMsg: "package file path cannot be empty", }, { name: "missing version for GitHub repo", assets: &github.ReleaseAssets{Repository: "owner/repo", Version: "", PackageFile: "/tmp/package.zip"}, errorMsg: "version cannot be empty for GitHub repository downloads", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err := client.DownloadReleaseAssets(ctx, tc.assets) require.Error(t, err) assert.ErrorContains(t, err, tc.errorMsg) }) } } func TestDownloadReleaseAssetsGitHubRelease(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) // Create mock server for GitHub releases server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Serve different content based on the requested file if strings.HasSuffix(path, "package.zip") { w.Header().Set("Content-Type", "application/zip") fmt.Fprint(w, "fake-zip-content") return } if strings.HasSuffix(path, "SHA256SUMS") { w.Header().Set("Content-Type", "text/plain") fmt.Fprint(w, "fake-checksum-content") return } if strings.HasSuffix(path, "SHA256SUMS.sig") { w.Header().Set("Content-Type", "text/plain") fmt.Fprint(w, "fake-signature-content") return } w.WriteHeader(http.StatusNotFound) return })) defer server.Close() // Use direct URL approach for testing since mock servers are complex to set up for GitHub releases format client := github.NewGitHubReleasesDownloadClient() assets := &github.ReleaseAssets{ Repository: server.URL + "/package.zip", // Direct URL PackageFile: filepath.Join(tempDir, "package.zip"), // Direct URLs don't use checksum files } ctx := context.Background() result, err := client.DownloadReleaseAssets(ctx, assets) require.NoError(t, err) // Verify result assert.Equal(t, assets.PackageFile, result.PackageFile) assert.Equal(t, "", result.ChecksumFile) assert.Equal(t, "", result.ChecksumSigFile) // Verify package file was created and has expected content verifyFileContent(t, result.PackageFile, "fake-zip-content") } func TestDownloadReleaseAssetsGitHubReleaseUsesToken(t *testing.T) { tempDir := helpers.TmpDirWOSymlinks(t) // shared logic for handlers doResp := func(w http.ResponseWriter, r *http.Request) { // Serve different content based on the requested file path := r.URL.Path if strings.HasSuffix(path, "package.zip") { w.Header().Set("Content-Type", "application/zip") fmt.Fprint(w, "fake-zip-content") return } if strings.HasSuffix(path, "SHA256SUMS") { w.Header().Set("Content-Type", "text/plain") fmt.Fprint(w, "fake-checksum-content") return } if strings.HasSuffix(path, "SHA256SUMS.sig") { w.Header().Set("Content-Type", "text/plain") fmt.Fprint(w, "fake-signature-content") return } w.WriteHeader(http.StatusNotFound) return } // Use direct URL approach for testing since mock servers are complex to set up for GitHub releases format client := github.NewGitHubReleasesDownloadClient() t.Run("prefer GH_TOKEN", func(t *testing.T) { t.Setenv("GH_TOKEN", "goodtoken") t.Setenv("GITHUB_TOKEN", "badtoken") // Create mock server for GitHub releases server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer goodtoken", r.Header.Get("Authorization")) doResp(w, r) })) defer server.Close() ctx := t.Context() assets := &github.ReleaseAssets{ Repository: server.URL + "/package.zip", // Direct URL PackageFile: filepath.Join(tempDir, "package.zip"), // Direct URLs don't use checksum files } _, err := client.DownloadReleaseAssets(ctx, assets) require.NoError(t, err) }) t.Run("use GITHUB_TOKEN", func(t *testing.T) { t.Setenv("GH_TOKEN", "") t.Setenv("GITHUB_TOKEN", "goodtoken") // Create mock server for GitHub releases server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer goodtoken", r.Header.Get("Authorization")) doResp(w, r) })) defer server.Close() ctx := t.Context() assets := &github.ReleaseAssets{ Repository: server.URL + "/package.zip", // Direct URL PackageFile: filepath.Join(tempDir, "package.zip"), // Direct URLs don't use checksum files } _, err := client.DownloadReleaseAssets(ctx, assets) require.NoError(t, err) }) } func TestDownloadReleaseAssetsDirectURL(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/zip") fmt.Fprint(w, "direct-url-content") })) defer server.Close() client := github.NewGitHubReleasesDownloadClient() assets := &github.ReleaseAssets{ Repository: server.URL + "/direct-download.zip", PackageFile: filepath.Join(tempDir, "direct.zip"), // Note: No Version, ChecksumFile, or ChecksumSigFile for direct URLs } result, err := client.DownloadReleaseAssets(t.Context(), assets) require.NoError(t, err) // Verify result assert.Equal(t, assets.PackageFile, result.PackageFile) assert.Equal(t, "", result.ChecksumFile) assert.Equal(t, "", result.ChecksumSigFile) // Verify file was created and has expected content verifyFileContent(t, result.PackageFile, "direct-url-content") } // Helper function to verify file content func verifyFileContent(t *testing.T, filePath, expectedContent string) { t.Helper() require.FileExists(t, filePath) content, err := os.ReadFile(filePath) require.NoError(t, err) assert.Equal(t, expectedContent, string(content)) } ================================================ FILE: internal/hclhelper/wrap.go ================================================ // Package hclhelper providers helpful tools for working with HCL values. package hclhelper import ( "fmt" "sort" "strings" ) // WrapMapToSingleLineHcl - This is a workaround to convert a map[string]any to a single line HCL string. func WrapMapToSingleLineHcl(m map[string]any) string { var attributes = make([]string, 0, len(m)) for key, value := range m { attributes = append(attributes, fmt.Sprintf(`%s=%s`, key, formatHclValue(value))) } sort.Strings(attributes) return fmt.Sprintf("{%s}", strings.Join(attributes, ",")) } // formatHclValue - Wrap single line HCL values in quotes. func formatHclValue(value any) string { switch v := value.(type) { case string: escapedValue := strings.ReplaceAll(v, `"`, `\"`) return fmt.Sprintf(`"%s"`, escapedValue) case map[string]any: return WrapMapToSingleLineHcl(v) default: return fmt.Sprintf(`%v`, v) } } ================================================ FILE: internal/hclhelper/wrap_test.go ================================================ package hclhelper_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/hclhelper" ) func TestWrapMapToSingleLineHcl(t *testing.T) { t.Parallel() testCases := []struct { name string input map[string]any expected string }{ { name: "SimpleMap", input: map[string]any{"key1": "value1", "key2": 46521694, "key3": true}, expected: `{key1="value1",key2=46521694,key3=true}`, }, { name: "NestedMap", input: map[string]any{"key1": "value1", "key2": map[string]any{"nestedKey": "nestedValue"}}, expected: `{key1="value1",key2={nestedKey="nestedValue"}}`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := hclhelper.WrapMapToSingleLineHcl(tc.input) if result != tc.expected { t.Errorf("Expected %s, but got %s", tc.expected, result) } }) } } ================================================ FILE: internal/iacargs/boolean_args_test.go ================================================ package iacargs_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/stretchr/testify/assert" ) func TestRemoveFlagWithLongBoolean(t *testing.T) { t.Parallel() // --json is an unknown flag (not in valueTakingFlags). // Unknown flags are treated as boolean, so removing --json // should NOT consume "planfile" as a value. args := &iacargs.IacArgs{ Flags: []string{"--json", "planfile"}, } args.RemoveFlag("--json") assert.Equal(t, []string{"planfile"}, args.Flags) } func TestRemoveFlagWithUnknownFlag(t *testing.T) { t.Parallel() // Unknown flag --unknown is not in valueTakingFlags. // Unknown flags are treated as boolean, so removing --unknown // should NOT consume "val" as a value. args := &iacargs.IacArgs{ Flags: []string{"--unknown", "val", "other"}, } args.RemoveFlag("--unknown") assert.Equal(t, []string{"val", "other"}, args.Flags) } func TestHasFlagFalsePositive(t *testing.T) { t.Parallel() // out=foo should not match -out flag args := &iacargs.IacArgs{ Flags: []string{"-var", "out=foo"}, } assert.False(t, args.HasFlag("-out")) } func TestRemoveFlagFalsePositive(t *testing.T) { t.Parallel() // Removing -out should not remove out=foo args := &iacargs.IacArgs{ Flags: []string{"-var", "out=foo"}, } args.RemoveFlag("-out") assert.Equal(t, []string{"-var", "out=foo"}, args.Flags) } func TestHasFlagDoubleDashMatch(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Flags: []string{"--help"}, } assert.True(t, args.HasFlag("-help")) assert.True(t, args.HasFlag("--help")) } func TestRemoveFlagDoubleDashMatch(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Flags: []string{"--help", "planfile"}, } args.RemoveFlag("-help") assert.Equal(t, []string{"planfile"}, args.Flags) } ================================================ FILE: internal/iacargs/iacargs.go ================================================ // Package iacargs provides types and utilities for handling IaC (terraform/tofu) CLI arguments. package iacargs import ( "regexp" "slices" "strings" ) const ( // minFlagLen is the minimum length for a valid flag (e.g., "-x" has length 2) minFlagLen = 2 // CommandNameDestroy is the terraform destroy command name CommandNameDestroy = "destroy" ) const ( SingleDashFlag NormalizeActsType = iota DoubleDashFlag ) var ( singleDashRegexp = regexp.MustCompile(`^-([^-]|$)`) doubleDashRegexp = regexp.MustCompile(`^--([^-]|$)`) ) type NormalizeActsType byte // valueTakingFlags contains flags that require space-separated values. // Only flags that commonly use a space-separated format need to be listed here. var valueTakingFlags = []string{ "chdir", "config", "from-module", "lock-timeout", "out", "parallelism", "plugin-dir", "state", "state-out", "backup", "target", "var", "var-file", } // normalizeFlag strips leading dashes from a flag name. func normalizeFlag(flag string) string { return strings.TrimLeft(flag, "-") } // isFlag returns true if the string starts with "-" (is a flag token). func isFlag(s string) bool { return strings.HasPrefix(s, "-") } // IacArgs represents parsed IaC (terraform/tofu) CLI arguments // with separate command, flags, and arguments fields. // Provides a builder pattern for constructing CLI arguments. // // Structure: [Command] [SubCommand...] [Flags...] [Arguments...] // - Command: main terraform command (e.g., "apply", "providers") // - SubCommand: non-flag args before any flags (e.g., "lock" in "providers lock") // - Flags: all flags with their values // - Arguments: non-flag args after flags (e.g., plan files) type IacArgs struct { Command string // e.g., "apply", "plan", "providers" SubCommand []string // e.g., "lock" in "providers lock -platform=..." Flags []string // e.g., "-input=false", "-auto-approve" Arguments []string // e.g., plan files, resource addresses } // New creates IacArgs from strings, parsing command/flags/arguments. func New(args ...string) *IacArgs { result := &IacArgs{ SubCommand: make([]string, 0), Flags: make([]string, 0), Arguments: make([]string, 0), } result.parse(args) return result } // SetCommand sets the command and returns self for chaining. func (a *IacArgs) SetCommand(cmd string) *IacArgs { a.Command = cmd return a } // AppendFlag adds flag(s) and returns self for chaining. func (a *IacArgs) AppendFlag(flags ...string) *IacArgs { a.Flags = append(a.Flags, flags...) return a } // InsertFlag inserts flag(s) at position and returns self for chaining. func (a *IacArgs) InsertFlag(pos int, flags ...string) *IacArgs { a.Flags = slices.Insert(a.Flags, pos, flags...) return a } // AppendArgument adds argument(s) and returns self for chaining. func (a *IacArgs) AppendArgument(args ...string) *IacArgs { a.Arguments = append(a.Arguments, args...) return a } // InsertArgument inserts an argument at position and returns self for chaining. func (a *IacArgs) InsertArgument(pos int, arg string) *IacArgs { a.Arguments = slices.Insert(a.Arguments, pos, arg) return a } // InsertArguments inserts arguments at position and returns self for chaining. func (a *IacArgs) InsertArguments(pos int, args ...string) *IacArgs { a.Arguments = slices.Insert(a.Arguments, pos, args...) return a } // AppendSubCommand adds subcommand(s) and returns self for chaining. func (a *IacArgs) AppendSubCommand(subs ...string) *IacArgs { a.SubCommand = append(a.SubCommand, subs...) return a } // AddFlagIfNotPresent adds a flag only if not already present. func (a *IacArgs) AddFlagIfNotPresent(flag string) *IacArgs { if !slices.Contains(a.Flags, flag) { a.Flags = append(a.Flags, flag) } return a } // HasFlag checks if flag exists (handles -flag, --flag and -flag=value formats). // Note: Values starting with "-" (like -module.resource) are indistinguishable from flags. func (a *IacArgs) HasFlag(name string) bool { target := normalizeFlag(name) return slices.ContainsFunc(a.Flags, func(f string) bool { if !isFlag(f) { return false } return normalizeFlag(extractFlagName(f)) == target }) } // RemoveFlag removes a flag by name (handles -flag, --flag, -flag=value, and space-separated -flag value). func (a *IacArgs) RemoveFlag(name string) *IacArgs { newFlags := make([]string, 0, len(a.Flags)) target := normalizeFlag(name) for i := 0; i < len(a.Flags); i++ { f := a.Flags[i] // Only treat tokens starting with "-" as potential flags if !isFlag(f) { newFlags = append(newFlags, f) continue } current := normalizeFlag(extractFlagName(f)) if current == target { // Skip value too if: no =value, next entry is a value, and it's a known value-taking flag hasNextValue := !strings.Contains(f, "=") && i+1 < len(a.Flags) && !strings.HasPrefix(a.Flags[i+1], "-") if hasNextValue && slices.Contains(valueTakingFlags, current) { i++ // skip the value } continue } newFlags = append(newFlags, f) } a.Flags = newFlags return a } // HasPlanFile checks if a plan file is already specified in args. // Checks for -out= flag (plan command) or any argument present (apply/destroy). func (a *IacArgs) HasPlanFile() bool { // Check for -out= flag (used with plan command) if a.HasFlag("out") { return true } // For apply/destroy: any argument is assumed to be a plan file // (can't reliably check file existence - path may be relative or created later) return len(a.Arguments) > 0 } // MergeFlags merges flags from another IacArgs, adding only flags not already present. // Handles both -flag=value and space-separated -flag value formats. // Returns self for chaining. func (a *IacArgs) MergeFlags(other *IacArgs) *IacArgs { for i := 0; i < len(other.Flags); i++ { flag := other.Flags[i] // Check if this is a flag with space-separated value hasValue := i+1 < len(other.Flags) && !strings.HasPrefix(other.Flags[i+1], "-") && !strings.Contains(flag, "=") && strings.HasPrefix(flag, "-") if hasValue { value := other.Flags[i+1] if !a.hasFlagWithValue(flag, value) { a.Flags = append(a.Flags, flag, value) } i++ // skip the value in iteration continue } if a.Contains(flag) { continue } a.Flags = append(a.Flags, flag) } return a } // hasFlagWithValue checks if a flag with specific value exists in either format. func (a *IacArgs) hasFlagWithValue(flag, value string) bool { // Check -flag=value format if slices.Contains(a.Flags, flag+"="+value) { return true } // Check space-separated format: find flag and verify next element is value if i := slices.Index(a.Flags, flag); i >= 0 && i+1 < len(a.Flags) { return a.Flags[i+1] == value } return false } // Slice returns args in correct order: [command] [flags...] [arguments...] func (a *IacArgs) Slice() []string { result := make([]string, 0, 1+len(a.SubCommand)+len(a.Flags)+len(a.Arguments)) if a.Command != "" { result = append(result, a.Command) } result = append(result, a.SubCommand...) result = append(result, a.Flags...) result = append(result, a.Arguments...) return result } // Clone returns a deep copy of IacArgs. // Note: This performs a deep copy of slices (Command, SubCommand, Flags, Arguments). // If IacArgs is extended with pointer fields or nested structs in the future, // this method must be updated to ensure deep copying of those fields as well. func (a *IacArgs) Clone() *IacArgs { return &IacArgs{ Command: a.Command, SubCommand: slices.Clone(a.SubCommand), Flags: slices.Clone(a.Flags), Arguments: slices.Clone(a.Arguments), } } // First returns the command (first element). func (a *IacArgs) First() string { return a.Command } // Second returns the second element (first subcommand, first flag, or first argument). func (a *IacArgs) Second() string { if len(a.SubCommand) > 0 { return a.SubCommand[0] } if len(a.Flags) > 0 { return a.Flags[0] } if len(a.Arguments) > 0 { return a.Arguments[0] } return "" } // Last returns the last element (last argument, or last flag, or command). func (a *IacArgs) Last() string { if len(a.Arguments) > 0 { return a.Arguments[len(a.Arguments)-1] } if len(a.Flags) > 0 { return a.Flags[len(a.Flags)-1] } return a.Command } // Tail returns everything except the command (subcommand, flags, and arguments) as a slice. func (a *IacArgs) Tail() []string { result := make([]string, 0, len(a.SubCommand)+len(a.Flags)+len(a.Arguments)) result = append(result, a.SubCommand...) result = append(result, a.Flags...) result = append(result, a.Arguments...) return result } // Contains checks if the args contain the target (in command, subcommand, flags, or arguments). func (a *IacArgs) Contains(target string) bool { return a.Command == target || slices.Contains(a.SubCommand, target) || slices.Contains(a.Flags, target) || slices.Contains(a.Arguments, target) } // Normalize formats the flags according to the given actions. func (a *IacArgs) Normalize(acts ...NormalizeActsType) *IacArgs { result := a.Clone() result.Flags = make([]string, 0, len(a.Flags)) for _, flag := range a.Flags { normalized := flag for _, act := range acts { switch act { case SingleDashFlag: if doubleDashRegexp.MatchString(normalized) { normalized = normalized[1:] } case DoubleDashFlag: if singleDashRegexp.MatchString(normalized) { normalized = "-" + normalized } } } result.Flags = append(result.Flags, normalized) } return result } // IsDestroyCommand returns true if this represents a destroy operation. // It checks both the command name and the -destroy flag. func (a *IacArgs) IsDestroyCommand(cmd string) bool { return cmd == CommandNameDestroy || a.Contains("-"+CommandNameDestroy) } // parse parses raw args into Command/SubCommand/Flags/Arguments. // Known terraform subcommands before any flags go to SubCommand (stay in place). // Other non-flag args go to Arguments (appear at end). func (a *IacArgs) parse(args []string) { skipNext := false seenFlag := false for i, arg := range args { if skipNext { skipNext = false continue } if strings.HasPrefix(arg, "-") { seenFlag = true skipNext = a.processFlag(arg, args, i) continue } if a.Command == "" { a.Command = arg continue } // Known subcommands before flags -> SubCommand (e.g., "lock" in "providers lock") if !seenFlag && IsKnownSubCommand(arg) { a.SubCommand = append(a.SubCommand, arg) continue } // All other non-flag args -> Arguments (e.g., plan files, resource addresses) a.Arguments = append(a.Arguments, arg) } } // knownSubCommands lists terraform subcommands that appear after the main command. // These should NOT be reordered to the end like plan files. // Maintainers: Add new Terraform/OpenTofu subcommands here as they are introduced. var knownSubCommands = []string{ // providers subcommands "lock", "mirror", "schema", // state subcommands "list", "mv", "pull", "push", "replace-provider", "rm", "show", // workspace subcommands "delete", "new", "select", // force-unlock takes an argument, not a subcommand } // IsKnownSubCommand returns true if arg is a known terraform subcommand. func IsKnownSubCommand(arg string) bool { return slices.Contains(knownSubCommands, arg) } // processFlag handles flag parsing, returns true if next arg should be skipped. // Unknown flags are assumed to be boolean. Only known value-taking flags consume the next arg. func (a *IacArgs) processFlag(arg string, args []string, i int) bool { // Malformed flag (just "-" or empty), or flag with inline value (-flag=value) if len(arg) < minFlagLen || strings.Contains(arg, "=") { a.Flags = append(a.Flags, arg) return false } // Known value-taking flag with next arg available that looks like a value flagName := normalizeFlag(extractFlagName(arg)) hasNextValue := i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") if slices.Contains(valueTakingFlags, flagName) && hasNextValue { a.Flags = append(a.Flags, arg, args[i+1]) return true } // Unknown flags and boolean flags are self-contained a.Flags = append(a.Flags, arg) return false } // extractFlagName gets flag name before = if present. func extractFlagName(arg string) string { name, _, _ := strings.Cut(arg, "=") return name } ================================================ FILE: internal/iacargs/iacargs_test.go ================================================ package iacargs_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/stretchr/testify/assert" ) func TestNew(t *testing.T) { t.Parallel() tests := []struct { name string input []string wantCmd string wantFlags []string wantArgs []string }{ { name: "simple apply", input: []string{"apply", "tfplan"}, wantCmd: "apply", wantFlags: nil, wantArgs: []string{"tfplan"}, }, { name: "apply with flags", input: []string{"apply", "-input=false", "tfplan"}, wantCmd: "apply", wantFlags: []string{"-input=false"}, wantArgs: []string{"tfplan"}, }, { name: "issue #5409 - plan file before flags", input: []string{"apply", "tfplan", "-input=false", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-input=false", "-auto-approve"}, wantArgs: []string{"tfplan"}, }, { name: "destroy case with plan file in middle", input: []string{"apply", "-destroy", "/tmp/x.tfplan", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-destroy", "-auto-approve"}, wantArgs: []string{"/tmp/x.tfplan"}, }, { name: "full destroy case with all flags", input: []string{"apply", "-no-color", "-destroy", "-input=false", "/tmp/plan.tfplan", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-no-color", "-destroy", "-input=false", "-auto-approve"}, wantArgs: []string{"/tmp/plan.tfplan"}, }, { name: "var with space-separated value", input: []string{"plan", "-var", "key=value", "-out=myplan"}, wantCmd: "plan", wantFlags: []string{"-var", "key=value", "-out=myplan"}, wantArgs: nil, }, { name: "target with space-separated value", input: []string{"plan", "-target", "module.foo", "tfplan"}, wantCmd: "plan", wantFlags: []string{"-target", "module.foo"}, wantArgs: []string{"tfplan"}, }, { name: "var-file with space-separated value", input: []string{"apply", "-var-file", "vars.tfvars"}, wantCmd: "apply", wantFlags: []string{"-var-file", "vars.tfvars"}, wantArgs: nil, }, { name: "lock-timeout with space-separated value", input: []string{"apply", "-lock-timeout", "5m", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-lock-timeout", "5m", "-auto-approve"}, wantArgs: nil, }, { // Unknown flags are treated as boolean. If a new Terraform flag needs // space-separated values, add it to valueTakingFlags list. name: "unknown flag treated as boolean", input: []string{"apply", "-future-flag", "value", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-future-flag", "-auto-approve"}, wantArgs: []string{"value"}, }, { // Unknown flags are boolean, so planfile correctly goes to Arguments. name: "unknown boolean flag followed by arg", input: []string{"apply", "-unknown-bool", "planfile"}, wantCmd: "apply", wantFlags: []string{"-unknown-bool"}, wantArgs: []string{"planfile"}, }, { name: "empty args", input: []string{}, wantCmd: "", wantFlags: nil, wantArgs: nil, }, { name: "only command", input: []string{"apply"}, wantCmd: "apply", wantFlags: nil, wantArgs: nil, }, { name: "only flags", input: []string{"-auto-approve"}, wantCmd: "", wantFlags: []string{"-auto-approve"}, wantArgs: nil, }, { name: "chdir with space-separated value", input: []string{"-chdir", "/tmp/dir", "apply"}, wantCmd: "apply", wantFlags: []string{"-chdir", "/tmp/dir"}, wantArgs: nil, }, { name: "chdir with equals value", input: []string{"-chdir=/tmp/dir", "plan"}, wantCmd: "plan", wantFlags: []string{"-chdir=/tmp/dir"}, wantArgs: nil, }, { name: "multiple positional args", input: []string{"apply", "plan1", "plan2", "-auto-approve"}, wantCmd: "apply", wantFlags: []string{"-auto-approve"}, wantArgs: []string{"plan1", "plan2"}, }, { name: "var with equals format", input: []string{"plan", "-var=key=value", "tfplan"}, wantCmd: "plan", wantFlags: []string{"-var=key=value"}, wantArgs: []string{"tfplan"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := iacargs.New(tt.input...) assert.Equal(t, tt.wantCmd, got.Command) if tt.wantFlags == nil { assert.Empty(t, got.Flags) } else { assert.Equal(t, tt.wantFlags, got.Flags) } if tt.wantArgs == nil { assert.Empty(t, got.Arguments) } else { assert.Equal(t, tt.wantArgs, got.Arguments) } }) } } func TestIacArgsSlice(t *testing.T) { t.Parallel() tests := []struct { name string input *iacargs.IacArgs want []string }{ { name: "basic apply with flags and args", input: &iacargs.IacArgs{ Command: "apply", Flags: []string{"-input=false", "-auto-approve"}, Arguments: []string{"tfplan"}, }, want: []string{"apply", "-input=false", "-auto-approve", "tfplan"}, }, { name: "command only", input: &iacargs.IacArgs{ Command: "plan", }, want: []string{"plan"}, }, { name: "empty", input: &iacargs.IacArgs{ Flags: []string{}, Arguments: []string{}, }, want: []string{}, }, { name: "flags only", input: &iacargs.IacArgs{ Flags: []string{"-auto-approve"}, }, want: []string{"-auto-approve"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := tt.input.Slice() assert.Equal(t, tt.want, got) }) } } func TestIacArgsRoundTrip(t *testing.T) { t.Parallel() tests := []struct { name string input []string want []string }{ { name: "issue #5409 - reorder plan file to end", input: []string{"apply", "tfplan", "-input=false", "-auto-approve"}, want: []string{"apply", "-input=false", "-auto-approve", "tfplan"}, }, { name: "already correct order", input: []string{"apply", "-input=false", "-auto-approve", "tfplan"}, want: []string{"apply", "-input=false", "-auto-approve", "tfplan"}, }, { name: "destroy with plan file in middle", input: []string{"apply", "-destroy", "/tmp/plan.tfplan", "-auto-approve"}, want: []string{"apply", "-destroy", "-auto-approve", "/tmp/plan.tfplan"}, }, { name: "providers lock subcommand preserves order", input: []string{"providers", "lock", "-platform=linux_amd64", "-platform=darwin_arm64"}, want: []string{"providers", "lock", "-platform=linux_amd64", "-platform=darwin_arm64"}, }, { name: "state mv subcommand preserves order", input: []string{"state", "mv", "-lock=false", "aws_instance.a", "aws_instance.b"}, want: []string{"state", "mv", "-lock=false", "aws_instance.a", "aws_instance.b"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() parsed := iacargs.New(tt.input...) got := parsed.Slice() assert.Equal(t, tt.want, got) }) } } func TestIacArgsAddFlagIfNotPresent(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Command: "apply", Flags: []string{"-auto-approve"}, } // Add new flag args.AddFlagIfNotPresent("-input=false") assert.Contains(t, args.Flags, "-input=false") // Adding duplicate should not add again args.AddFlagIfNotPresent("-auto-approve") count := 0 for _, f := range args.Flags { if f == "-auto-approve" { count++ } } assert.Equal(t, 1, count) } func TestIacArgsHasFlag(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Flags: []string{"-auto-approve", "-input=false"}, } assert.True(t, args.HasFlag("-auto-approve")) assert.True(t, args.HasFlag("-input")) assert.False(t, args.HasFlag("-destroy")) } func TestIacArgsRemoveFlag(t *testing.T) { t.Parallel() tests := []struct { name string initialFlags []string flagToRemove string expectedFlags []string }{ { name: "remove flag with equals value", initialFlags: []string{"-auto-approve", "-input=false", "-destroy"}, flagToRemove: "-input", expectedFlags: []string{"-auto-approve", "-destroy"}, }, { name: "remove boolean flag", initialFlags: []string{"-auto-approve", "-destroy"}, flagToRemove: "-auto-approve", expectedFlags: []string{"-destroy"}, }, { name: "remove flag with space-separated value", initialFlags: []string{"-var", "foo=bar", "-auto-approve"}, flagToRemove: "-var", expectedFlags: []string{"-auto-approve"}, }, { name: "remove flag where next entry looks like flag preserves it", initialFlags: []string{"-target", "-module.resource", "-auto-approve"}, flagToRemove: "-target", expectedFlags: []string{"-module.resource", "-auto-approve"}, }, { name: "remove flag preserves other flags with dash-prefixed values", initialFlags: []string{"-var", "key=-value", "-target", "-module.foo", "-destroy"}, flagToRemove: "-var", expectedFlags: []string{"-target", "-module.foo", "-destroy"}, }, { name: "remove middle flag with space-separated value", initialFlags: []string{"-auto-approve", "-var", "x=y", "-destroy"}, flagToRemove: "-var", expectedFlags: []string{"-auto-approve", "-destroy"}, }, { name: "remove nonexistent flag does nothing", initialFlags: []string{"-auto-approve", "-destroy"}, flagToRemove: "-nonexistent", expectedFlags: []string{"-auto-approve", "-destroy"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Flags: append([]string{}, tt.initialFlags...), } args.RemoveFlag(tt.flagToRemove) assert.Equal(t, tt.expectedFlags, args.Flags) }) } } func TestIacArgsAppendArgument(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Command: "apply", Arguments: []string{"plan1"}, } args.AppendArgument("plan2") assert.Equal(t, []string{"plan1", "plan2"}, args.Arguments) } func TestIacArgsClone(t *testing.T) { t.Parallel() original := &iacargs.IacArgs{ Command: "apply", Flags: []string{"-auto-approve"}, Arguments: []string{"tfplan"}, } clone := original.Clone() // Verify values are equal assert.Equal(t, original.Command, clone.Command) assert.Equal(t, original.Flags, clone.Flags) assert.Equal(t, original.Arguments, clone.Arguments) // Verify modifying clone doesn't affect original clone.Command = "plan" clone.Flags = append(clone.Flags, "-input=false") clone.Arguments = append(clone.Arguments, "another") assert.Equal(t, "apply", original.Command) assert.Equal(t, []string{"-auto-approve"}, original.Flags) assert.Equal(t, []string{"tfplan"}, original.Arguments) } func TestIacArgsContains(t *testing.T) { t.Parallel() args := &iacargs.IacArgs{ Command: "apply", Flags: []string{"-auto-approve", "-input=false"}, Arguments: []string{"tfplan"}, } assert.True(t, args.Contains("apply")) assert.True(t, args.Contains("-auto-approve")) assert.True(t, args.Contains("tfplan")) assert.False(t, args.Contains("-destroy")) } func TestIacArgsFirst(t *testing.T) { t.Parallel() args := iacargs.New("apply", "-auto-approve", "tfplan") assert.Equal(t, "apply", args.First()) empty := iacargs.New() assert.Empty(t, empty.First()) } func TestIacArgsTail(t *testing.T) { t.Parallel() args := iacargs.New("apply", "-auto-approve", "tfplan") assert.Equal(t, []string{"-auto-approve", "tfplan"}, args.Tail()) empty := iacargs.New() assert.Empty(t, empty.Tail()) } func TestIacArgsHasPlanFile(t *testing.T) { t.Parallel() tests := []struct { args *iacargs.IacArgs name string expected bool }{ { name: "empty args", args: iacargs.New(), expected: false, }, { name: "plan with -out flag", args: iacargs.New("plan", "-out=tfplan"), expected: true, }, { name: "plan without -out flag", args: iacargs.New("plan", "-input=false"), expected: false, }, { name: "apply with plan file argument", args: iacargs.New("apply", "-auto-approve", "tfplan"), expected: true, }, { name: "apply without plan file", args: iacargs.New("apply", "-auto-approve"), expected: false, }, { name: "destroy with plan file argument", args: iacargs.New("destroy", "tfplan"), expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.args.HasPlanFile()) }) } } func TestIacArgsMergeFlags(t *testing.T) { t.Parallel() tests := []struct { name string base *iacargs.IacArgs other *iacargs.IacArgs expectedFlags []string }{ { name: "merge into empty", base: iacargs.New("apply"), other: iacargs.New("apply", "-auto-approve", "-input=false"), expectedFlags: []string{"-auto-approve", "-input=false"}, }, { name: "skip duplicates", base: iacargs.New("apply", "-auto-approve"), other: iacargs.New("apply", "-auto-approve", "-input=false"), expectedFlags: []string{"-auto-approve", "-input=false"}, }, { name: "merge from empty", base: iacargs.New("apply", "-auto-approve"), other: iacargs.New(), expectedFlags: []string{"-auto-approve"}, }, { name: "both have different flags", base: iacargs.New("apply", "-compact-warnings"), other: iacargs.New("apply", "-auto-approve", "-input=false"), expectedFlags: []string{"-compact-warnings", "-auto-approve", "-input=false"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tt.base.MergeFlags(tt.other) assert.Equal(t, tt.expectedFlags, tt.base.Flags) }) } } func TestIacArgsIsDestroyCommand(t *testing.T) { t.Parallel() tests := []struct { name string args *iacargs.IacArgs cmd string expected bool }{ { name: "destroy command", args: iacargs.New("destroy"), cmd: "destroy", expected: true, }, { name: "apply with -destroy flag", args: iacargs.New("apply", "-destroy", "tfplan"), cmd: "apply", expected: true, }, { name: "regular apply", args: iacargs.New("apply", "-auto-approve"), cmd: "apply", expected: false, }, { name: "plan command", args: iacargs.New("plan"), cmd: "plan", expected: false, }, { name: "nil args with destroy cmd", args: nil, cmd: "destroy", expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if tt.args == nil { // Test nil case separately assert.Equal(t, tt.expected, tt.cmd == "destroy") } else { assert.Equal(t, tt.expected, tt.args.IsDestroyCommand(tt.cmd)) } }) } } ================================================ FILE: internal/iam/iam.go ================================================ // Package iam provides shared types for IAM role configuration used across // multiple packages (options, config, awshelper, etc.). package iam import ( "fmt" "time" ) const ( // DefaultAssumeRoleDuration is the default session duration in seconds for IAM role assumption. DefaultAssumeRoleDuration = 3600 ) // GetDefaultAssumeRoleSessionName returns a unique session name for IAM role assumption. func GetDefaultAssumeRoleSessionName() string { return fmt.Sprintf("terragrunt-%d", time.Now().UTC().UnixNano()) } // RoleOptions represents options that are used by Terragrunt to assume an IAM role. type RoleOptions struct { RoleARN string WebIdentityToken string AssumeRoleSessionName string AssumeRoleDuration int64 } // MergeRoleOptions merges the source IAM role options into the target, preferring // non-zero source values. func MergeRoleOptions(target RoleOptions, source RoleOptions) RoleOptions { out := target if source.RoleARN != "" { out.RoleARN = source.RoleARN } if source.AssumeRoleDuration != 0 { out.AssumeRoleDuration = source.AssumeRoleDuration } if source.AssumeRoleSessionName != "" { out.AssumeRoleSessionName = source.AssumeRoleSessionName } if source.WebIdentityToken != "" { out.WebIdentityToken = source.WebIdentityToken } return out } ================================================ FILE: internal/locks/lock.go ================================================ // Package locks contains global locks used throughout Terragrunt. package locks import "sync" // EnvLock is the lock acquired when writing environment variables in a way // that is not safe for concurrent access. // // When possible, prefer to spawn a new process with the environment variables // you want, or avoid setting environment variables instead of using this lock. var EnvLock sync.Mutex //nolint:gochecknoglobals ================================================ FILE: internal/os/exec/cmd.go ================================================ // Package exec runs external commands. It wraps exec.Cmd package with support for allocating a pseudo-terminal. package exec import ( "context" "os" "os/exec" "path/filepath" "sync/atomic" "time" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/pkg/log" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/gruntwork-io/terragrunt/internal/errors" ) // DefaultGracefulShutdownDelay is the default time to wait for a process to exit // gracefully after sending an interrupt signal before escalating to SIGKILL. const DefaultGracefulShutdownDelay = 30 * time.Second // Cmd is a command type. type Cmd struct { logger log.Logger interruptSignal os.Signal *exec.Cmd filename string forwardSignalDelay time.Duration usePTY bool gracefulShutdownRegistered atomic.Bool } // Command returns the `Cmd` struct to execute the named program with // the given arguments. func Command(ctx context.Context, name string, args ...string) *Cmd { cmd := &Cmd{ Cmd: exec.CommandContext(ctx, name, args...), logger: log.Default(), filename: filepath.Base(name), interruptSignal: signal.InterruptSignal, } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.WaitDelay = DefaultGracefulShutdownDelay cmd.Cancel = func() error { if cmd.gracefulShutdownRegistered.Load() { return nil } if cmd.Process == nil { return nil } if sig := signal.SignalFromContext(ctx); sig != nil { return cmd.Process.Signal(sig) } if cmd.interruptSignal != nil { return cmd.Process.Signal(cmd.interruptSignal) } return cmd.Process.Signal(os.Kill) } return cmd } // Configure sets options to the `Cmd`. func (cmd *Cmd) Configure(opts ...Option) { for _, opt := range opts { opt(cmd) } } // Start starts the specified command but does not wait for it to complete. func (cmd *Cmd) Start() error { // If we need to allocate a ptty for the command, route through the ptty routine. // Otherwise, directly call the command. if cmd.usePTY { if err := runCommandWithPTY(cmd.logger, cmd.Cmd); err != nil { return err } } else if err := cmd.Cmd.Start(); err != nil { return errors.New(err) } return nil } // RegisterGracefullyShutdown registers a graceful shutdown for the command in two ways: // 1. If the context cancel contains a cause with a signal, this means that Terragrunt received the signal from the OS, // since our executed command may also receive the same signal, we need to give the command time to gracefully shutting down, // to avoid the command receiving this signal twice. // Thus we will send the signal to the executed command with a delay or immediately if Terragrunt receives this same signal again. // 2. If the context does not contain any causes, this means that there was some failure and we need to terminate all executed commands, // in this situation we are sure that commands did not receive any signal, so we send them an interrupt signal immediately. func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context) func() { cmd.gracefulShutdownRegistered.Store(true) ctxShutdown, cancelShutdown := context.WithCancel(context.Background()) go func() { select { case <-ctxShutdown.Done(): case <-ctx.Done(): if cause := new(signal.ContextCanceledError); errors.As(context.Cause(ctx), &cause) && cause.Signal != nil { cmd.ForwardSignal(ctxShutdown, cause.Signal) return } cmd.SendSignal(cmd.interruptSignal) } }() return cancelShutdown } // ForwardSignal forwards a given `sig` with a delay if cmd.forwardSignalDelay is greater than 0, // and if the same signal is received again, it is forwarded immediately. func (cmd *Cmd) ForwardSignal(ctx context.Context, sig os.Signal) { ctxDelay, cancelDelay := context.WithCancel(ctx) defer cancelDelay() signal.NotifierWithContext(ctx, func(_ os.Signal) { cancelDelay() }, sig) if cmd.forwardSignalDelay > 0 { cmd.logger.Debugf("%s signal will be forwarded to %s with delay %s", cases.Title(language.English).String(sig.String()), cmd.filename, cmd.forwardSignalDelay, ) } select { case <-ctx.Done(): return case <-time.After(cmd.forwardSignalDelay): case <-ctxDelay.Done(): } cmd.SendSignal(sig) } // SendSignal sends the given `sig` to the executed command. func (cmd *Cmd) SendSignal(sig os.Signal) { cmd.logger.Debugf("%s signal is forwarded to %s", cases.Title(language.English).String(sig.String()), cmd.filename) if err := cmd.Process.Signal(sig); err != nil { cmd.logger.Errorf("Failed to forwarding signal %s to %s: %v", sig, cmd.filename, err) } } ================================================ FILE: internal/os/exec/cmd_unix_test.go ================================================ //go:build linux || darwin // +build linux darwin package exec_test import ( "context" "errors" "os" "strconv" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( errExplicitError = errors.New("this is an explicit error") ) func TestExitCodeUnix(t *testing.T) { t.Parallel() for index := 0; index <= 255; index++ { cmd := exec.Command(t.Context(), "testdata/test_exit_code.sh", strconv.Itoa(index)) err := cmd.Run() if index == 0 { require.NoError(t, err) } else { require.Error(t, err) } retCode, err := util.GetExitCode(err) require.NoError(t, err) assert.Equal(t, index, retCode) } // assert a non exec.ExitError returns an error retCode, retErr := util.GetExitCode(errExplicitError) require.Error(t, retErr, "An error was expected") assert.Equal(t, errExplicitError, retErr) assert.Equal(t, 0, retCode) } func TestNewSignalsForwarderWaitUnix(t *testing.T) { t.Parallel() expectedWait := 5 cmd := exec.Command(t.Context(), "testdata/test_sigint_wait.sh", strconv.Itoa(expectedWait)) runChannel := make(chan error) go func() { runChannel <- cmd.Run() }() time.Sleep(time.Second) start := time.Now() cmd.Process.Signal(os.Interrupt) err := <-runChannel require.Error(t, err) retCode, err := util.GetExitCode(err) require.NoError(t, err) assert.Equal(t, expectedWait, retCode) assert.WithinDuration(t, time.Now(), start.Add(time.Duration(expectedWait)*time.Second), time.Second, "Expected to wait 5 (+/-1) seconds after SIGINT") } // There isn't a proper way to catch interrupts in Windows batch scripts, so this test exists only for Unix. func TestNewSignalsForwarderMultipleUnix(t *testing.T) { t.Parallel() expectedInterrupts := 10 cmd := exec.Command(t.Context(), "testdata/test_sigint_multiple.sh", strconv.Itoa(expectedInterrupts)) runChannel := make(chan error) go func() { runChannel <- cmd.Run() }() time.Sleep(time.Second) interruptAndWaitForProcess := func() (int, error) { var ( interrupts int err error ) for { time.Sleep(500 * time.Millisecond) select { case err = <-runChannel: return interrupts, err default: cmd.Process.Signal(os.Interrupt) interrupts++ } } } interrupts, err := interruptAndWaitForProcess() require.Error(t, err) retCode, err := util.GetExitCode(err) require.NoError(t, err) assert.LessOrEqual(t, retCode, interrupts, "Subprocess received wrong number of signals") assert.Equal(t, expectedInterrupts, retCode, "Subprocess didn't receive multiple signals") } // TestGracefulShutdownOnContextCancelUnix verifies that when the context is cancelled // without a signal cause, the Cancel callback sends SIGINT (not SIGKILL) to allow // processes like Terraform to gracefully shutdown their child processes. // The test script traps SIGINT and exits with code 42, while SIGKILL would terminate // it immediately without running the trap handler. func TestGracefulShutdownOnContextCancelUnix(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cmd := exec.Command(ctx, "testdata/test_graceful_shutdown.sh") cmd.Configure(exec.WithGracefulShutdownDelay(5 * time.Second)) runChannel := make(chan error) go func() { runChannel <- cmd.Run() }() time.Sleep(500 * time.Millisecond) cancel() err := <-runChannel require.Error(t, err) retCode, err := util.GetExitCode(err) require.NoError(t, err) assert.Equal( t, 42, retCode, "Expected exit code 42 (SIGINT received), but got %d. "+ "This suggests SIGKILL was sent instead of SIGINT.", retCode, ) } ================================================ FILE: internal/os/exec/cmd_windows_test.go ================================================ //go:build windows package exec_test import ( "bytes" "errors" "os" "strconv" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/stretchr/testify/assert" ) func TestWindowsConsolePrepare(t *testing.T) { t.Parallel() stdout := bytes.Buffer{} l := log.New(log.WithOutput(&stdout), log.WithLevel(log.DebugLevel)) // In a test environment, handles are not real console handles, // so PrepareConsole should return false. result := exec.PrepareConsole(l) assert.False(t, result, "PrepareConsole should return false when handles are invalid") assert.Contains(t, stdout.String(), "failed to get console mode") } func TestWindowsExitCode(t *testing.T) { t.Parallel() for i := 0; i <= 255; i++ { cmd := exec.Command(t.Context(), `testdata\test_exit_code.bat`, strconv.Itoa(i)) err := cmd.Run() if i == 0 { assert.NoError(t, err) } else { assert.Error(t, err) } retCode, err := util.GetExitCode(err) assert.NoError(t, err) assert.Equal(t, i, retCode) } // assert a non exec.ExitError returns an error err := errors.New("This is an explicit error") retCode, retErr := util.GetExitCode(err) assert.Error(t, retErr, "An error was expected") assert.Equal(t, err, retErr) assert.Equal(t, 0, retCode) } func TestWindowsNewSignalsForwarderWait(t *testing.T) { t.Parallel() expectedWait := 5 cmd := exec.Command(t.Context(), `testdata\test_sigint_wait.bat`, strconv.Itoa(expectedWait)) runChannel := make(chan error) go func() { runChannel <- cmd.Run() }() time.Sleep(time.Second) // start := time.Now() // Note: sending interrupt on Windows is not supported by Windows and not implemented in Go if cmd.Process != nil { // on some Go versions(Go 1.23, Windows), cmd.Process is nil cmd.Process.Signal(os.Kill) } err := <-runChannel assert.Error(t, err) // Since we can't send an interrupt on Windows, our test script won't handle it gracefully and exit after the expected wait time, // so this part of the test process cannot be done on Windows // retCode, err := GetExitCode(err) // assert.NoError(t, err) // assert.Equal(t, retCode, expectedWait) // assert.WithinDuration(t, start.Add(time.Duration(expectedWait)*time.Second), time.Now(), time.Second, // "Expected to wait 5 (+/-1) seconds after SIGINT") } ================================================ FILE: internal/os/exec/console_windows_test.go ================================================ //go:build windows package exec_test import ( "os" "testing" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" ) // openConsoleOutput opens CONOUT$ directly, which always returns a real // console handle even when os.Stdin/os.Stdout are pipes (e.g. in GitHub Actions). // Skips the test if no console is attached at all. func openConsoleOutput(t *testing.T) *os.File { t.Helper() f, err := os.OpenFile("CONOUT$", os.O_RDWR, 0) if err != nil { t.Skipf("skipping: no console attached (CONOUT$ unavailable): %v", err) } t.Cleanup(func() { f.Close() }) var mode uint32 if err := windows.GetConsoleMode(windows.Handle(f.Fd()), &mode); err != nil { f.Close() t.Skipf("skipping: CONOUT$ is not a usable console handle: %v", err) } return f } func openConsoleInput(t *testing.T) *os.File { t.Helper() f, err := os.OpenFile("CONIN$", os.O_RDWR, 0) if err != nil { t.Skipf("skipping: no console attached (CONIN$ unavailable): %v", err) } t.Cleanup(func() { f.Close() }) var mode uint32 if err := windows.GetConsoleMode(windows.Handle(f.Fd()), &mode); err != nil { f.Close() t.Skipf("skipping: CONIN$ is not a usable console handle: %v", err) } return f } func getMode(t *testing.T, f *os.File) uint32 { t.Helper() var mode uint32 require.NoError(t, windows.GetConsoleMode(windows.Handle(f.Fd()), &mode)) return mode } func setMode(t *testing.T, f *os.File, mode uint32) { t.Helper() require.NoError(t, windows.SetConsoleMode(windows.Handle(f.Fd()), mode)) } // TestWindowsConsoleStateOnPipes verifies that SaveConsoleState and Restore // work without error when standard handles are pipes (CI). The saved state // should round-trip: save then restore should not change the console mode. func TestWindowsConsoleStateOnPipes(t *testing.T) { t.Parallel() var beforeMode uint32 stdoutIsConsole := windows.GetConsoleMode(windows.Handle(os.Stdout.Fd()), &beforeMode) == nil saved := exec.SaveConsoleState() saved.Restore() var afterMode uint32 afterIsConsole := windows.GetConsoleMode(windows.Handle(os.Stdout.Fd()), &afterMode) == nil assert.Equal(t, stdoutIsConsole, afterIsConsole, "stdout console status should not change after save/restore") assert.Equal(t, beforeMode, afterMode, "stdout console mode should be unchanged after save/restore") } // TestWindowsConsolePrepareStdinOnPipes verifies PrepareStdinForPrompt handles // pipe stdin gracefully and does not corrupt console mode. func TestWindowsConsolePrepareStdinOnPipes(t *testing.T) { t.Parallel() var beforeMode uint32 stdinIsConsole := windows.GetConsoleMode(windows.Handle(os.Stdin.Fd()), &beforeMode) == nil l := log.New(log.WithLevel(log.DebugLevel)) exec.PrepareStdinForPrompt(l) var afterMode uint32 afterIsConsole := windows.GetConsoleMode(windows.Handle(os.Stdin.Fd()), &afterMode) == nil assert.Equal(t, stdinIsConsole, afterIsConsole, "stdin console status should not change after PrepareStdinForPrompt") assert.Equal(t, beforeMode, afterMode, "stdin console mode should be unchanged after PrepareStdinForPrompt on pipes") } // TestWindowsConsoleVTProcessingOnCONOUT verifies that VT processing can be // toggled on a real console handle via raw API calls. func TestWindowsConsoleVTProcessingOnCONOUT(t *testing.T) { t.Parallel() conout := openConsoleOutput(t) original := getMode(t, conout) defer setMode(t, conout, original) cleared := original &^ windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING setMode(t, conout, cleared) assert.Equal(t, uint32(0), getMode(t, conout)&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING, "VT bit should be cleared before test") setMode(t, conout, cleared|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) assert.NotEqual(t, uint32(0), getMode(t, conout)&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING, "VT processing should be enabled on CONOUT$") } // TestWindowsConsoleSaveRestoreOnCONOUT verifies the full save→corrupt→restore // cycle using a real console handle from CONOUT$. This is the core regression // test: subprocesses like "terraform version" clear VT processing, and Restore // must bring it back. func TestWindowsConsoleSaveRestoreOnCONOUT(t *testing.T) { t.Parallel() conout := openConsoleOutput(t) original := getMode(t, conout) defer setMode(t, conout, original) withVT := original | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING setMode(t, conout, withVT) before := getMode(t, conout) require.Equal(t, withVT, before) saved := before corrupted := before &^ windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING setMode(t, conout, corrupted) assert.Equal(t, uint32(0), getMode(t, conout)&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING, "VT should be cleared after simulated subprocess corruption") setMode(t, conout, saved) after := getMode(t, conout) assert.Equal(t, before, after, "console mode must be identical after save→corrupt→restore cycle") } // TestWindowsConsoleStdinFlagsOnCONIN verifies stdin prompt flags can be // cleared and restored via raw API on a real console input handle. func TestWindowsConsoleStdinFlagsOnCONIN(t *testing.T) { t.Parallel() conin := openConsoleInput(t) original := getMode(t, conin) defer setMode(t, conin, original) required := uint32(windows.ENABLE_LINE_INPUT | windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT) assert.Equal(t, required, original&required, "a default console input handle should have LINE_INPUT, ECHO_INPUT, PROCESSED_INPUT") setMode(t, conin, original&^required) assert.Equal(t, uint32(0), getMode(t, conin)&required, "required flags should be cleared after corruption") setMode(t, conin, original) assert.Equal(t, required, getMode(t, conin)&required, "required flags should be restored") } // TestWindowsConsoleSubprocessSaveRestore is an integration test that runs a // real subprocess and verifies the save→subprocess→restore pattern preserves // console modes. Uses CONOUT$ for a real console handle. func TestWindowsConsoleSubprocessSaveRestore(t *testing.T) { t.Parallel() conout := openConsoleOutput(t) original := getMode(t, conout) defer setMode(t, conout, original) withVT := original | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING setMode(t, conout, withVT) before := getMode(t, conout) cmd := exec.Command(t.Context(), "cmd.exe", "/C", "echo hello") cmd.Stdout = nil cmd.Stderr = nil saved := exec.SaveConsoleState() require.NoError(t, cmd.Run()) saved.Restore() setMode(t, conout, before) after := getMode(t, conout) assert.Equal(t, before, after, "CONOUT$ mode should be unchanged after save→subprocess→restore") } ================================================ FILE: internal/os/exec/opts.go ================================================ package exec import ( "time" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/pkg/log" ) const envVarsListFormat = "%s=%s" // Option is type for passing options to the Cmd. type Option func(*Cmd) // WithLogger sets Logger to the Cmd. func WithLogger(logger log.Logger) Option { return func(cmd *Cmd) { cmd.logger = logger } } // WithUsePTY enables a pty for the Cmd. func WithUsePTY(state bool) Option { return func(cmd *Cmd) { cmd.usePTY = state } } // WithEnv sets envs to the Cmd. func WithEnv(env map[string]string) Option { return func(cmd *Cmd) { cmd.Env = collections.KeyValueStringSliceWithFormat(env, envVarsListFormat) } } // WithForwardSignalDelay sets forwarding signal delay to the Cmd. func WithForwardSignalDelay(delay time.Duration) Option { return func(cmd *Cmd) { cmd.forwardSignalDelay = delay } } // WithGracefulShutdownDelay sets the time to wait for a process to exit gracefully // after sending an interrupt signal before escalating to SIGKILL. // This allows processes like Terraform to clean up child processes (e.g., provider plugins). func WithGracefulShutdownDelay(delay time.Duration) Option { return func(cmd *Cmd) { cmd.WaitDelay = delay } } ================================================ FILE: internal/os/exec/ptty_unix.go ================================================ //go:build !windows package exec import ( "context" "io" "os" "os/exec" "os/signal" "syscall" "golang.org/x/sync/errgroup" "golang.org/x/term" "github.com/creack/pty" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // runCommandWithPTY will allocate a pseudo-tty to run the subcommand in. This is only necessary when running // interactive commands, so that terminal features like readline work through the subcommand when stdin, stdout, and // stderr is being shared. // NOTE: This is based on the quickstart example from https://github.com/creack/pty func runCommandWithPTY(logger log.Logger, cmd *exec.Cmd) (err error) { cmdStdout := cmd.Stdout cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil // NOTE: in order to ensure we can return errors that occur in cleanup, we use a variable binding for the return // value so that it can be updated. pseudoTerminal, err := pty.Start(cmd) if err != nil { return errors.New(err) } defer func() { if closeErr := pseudoTerminal.Close(); closeErr != nil { closeErr = errors.Errorf("Error closing pty: %w", closeErr) // Only overwrite the previous error if there was no error since this error has lower priority than any // errors in the main routine if err == nil { err = closeErr } else { logger.Error(closeErr) } } }() // Every time the current terminal size changes, we need to make sure the PTY also updates the size. ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGWINCH) go func() { for range ch { if inheritSizeErr := pty.InheritSize(os.Stdin, pseudoTerminal); inheritSizeErr != nil { inheritSizeErr = errors.Errorf("Error resizing pty: %w", inheritSizeErr) // We don't propagate this error upstream because it does not affect normal operation of the command logger.Error(inheritSizeErr) } } }() ch <- syscall.SIGWINCH // Make sure the pty matches current size // Set stdin in raw mode so that we preserve readline properties oldState, err := term.MakeRaw(int(os.Stdin.Fd())) if err != nil { return errors.New(err) } defer func() { if restoreErr := term.Restore(int(os.Stdin.Fd()), oldState); restoreErr != nil { restoreErr = errors.Errorf("error restoring terminal state: %w", restoreErr) // Only overwrite the previous error if there was no error since this error has lower priority than any // errors in the main routine if err == nil { err = restoreErr } else { logger.Error(restoreErr) } } }() ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() errGroup, ctx := errgroup.WithContext(ctx) // Copy stdout to the pty. errGroup.Go(func() error { defer cancel() if _, err := util.Copy(ctx, cmdStdout, pseudoTerminal); err != nil { return errors.Errorf("error forwarding stdout: %w", err) } return nil }) // Copy stdin to the pty. errGroup.Go(func() error { defer cancel() if _, err := util.Copy(ctx, pseudoTerminal, os.Stdin); err != nil { return errors.Errorf("error forwarding stdin: %w", err) } return nil }) if err := errGroup.Wait(); err != nil && !errors.IsError(err, io.EOF) && !errors.IsContextCanceled(err) { return errors.New(err) } return nil } // PrepareConsole is run at the start of the application to set up the console. // On Unix, terminals handle ANSI escape sequences natively, so this is a no-op. // Returns true to indicate ANSI support is available. func PrepareConsole(_ log.Logger) bool { return true } // ConsoleState is a no-op on Unix. On Windows it saves/restores console modes // that subprocesses may modify. type ConsoleState struct{} // SaveConsoleState is a no-op on Unix. func SaveConsoleState() ConsoleState { return ConsoleState{} } // Restore is a no-op on Unix. func (ConsoleState) Restore() {} // PrepareStdinForPrompt is a no-op on Unix. func PrepareStdinForPrompt(_ log.Logger) {} ================================================ FILE: internal/os/exec/ptty_windows.go ================================================ //go:build windows package exec import ( "os" "os/exec" "strings" "golang.org/x/sys/windows" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) const InvalidHandleErrorMessage = "The handle is invalid" // PrepareConsole enables support for escape sequences on Windows. // Returns true if virtual terminal processing was successfully enabled on at least one output handle. // https://stackoverflow.com/questions/56460651/golang-fmt-print-033c-and-fmt-print-x1bc-are-not-clearing-screenansi-es // https://github.com/containerd/console/blob/f652dc3/console_windows.go#L46 func PrepareConsole(logger log.Logger) bool { enableVirtualTerminalInput(logger, os.Stdin) stdoutOK := enableVirtualTerminalProcessing(logger, os.Stdout) stderrOK := enableVirtualTerminalProcessing(logger, os.Stderr) if stdoutOK || stderrOK { return true } // If stdout/stderr are not console handles (e.g. pipes), try CONOUT$ directly. // CONOUT$ always refers to the active console output device. VT processing is a // screen buffer property that persists after the handle is closed, so enabling it // here affects all future console output even if stdout itself is a pipe. // Returning true is correct because stderr may still render to the console. conout, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) if err != nil { logger.Debugf("Could not open CONOUT$: %v", err) return false } defer conout.Close() return enableVirtualTerminalProcessing(logger, conout) } // enableVirtualTerminalInput sets ENABLE_VIRTUAL_TERMINAL_INPUT on an input handle (stdin). // This is separate from enableVirtualTerminalProcessing because input and output handles // use different flag values: ENABLE_VIRTUAL_TERMINAL_INPUT (0x200) for input vs // ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x4) for output. // VT input is optional — failures are logged at Debug level (not Error) because // missing VT input support does not break colored output or core functionality. func enableVirtualTerminalInput(logger log.Logger, file *os.File) { var mode uint32 handle := windows.Handle(file.Fd()) if err := windows.GetConsoleMode(handle, &mode); err != nil { logger.Debugf("failed to get console mode for input: %v", err) return } if err := windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { logger.Debugf("virtual terminal input not supported: %v", err) // Restore original mode in case the failed call left the handle in a bad state. _ = windows.SetConsoleMode(handle, mode) } } // PrepareStdinForPrompt ensures stdin has the console mode flags required for // interactive line input (line buffering, echo, processed input). Subprocesses // on Windows can clear these flags, making stdin unusable for prompts. func PrepareStdinForPrompt(logger log.Logger) { var mode uint32 handle := windows.Handle(os.Stdin.Fd()) if err := windows.GetConsoleMode(handle, &mode); err != nil { // stdin is not a console handle (e.g. pipe) — nothing to restore. return } required := uint32(windows.ENABLE_LINE_INPUT | windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT) if mode&required != required { if err := windows.SetConsoleMode(handle, mode|required); err != nil { logger.Debugf("failed to restore stdin console mode for prompt: %v", err) } } } // enableVirtualTerminalProcessing sets ENABLE_VIRTUAL_TERMINAL_PROCESSING on an output handle // (stdout or stderr) so that ANSI escape sequences are interpreted by the console. // Returns true if the flag was successfully set. func enableVirtualTerminalProcessing(logger log.Logger, file *os.File) bool { var mode uint32 handle := windows.Handle(file.Fd()) if err := windows.GetConsoleMode(handle, &mode); err != nil { if strings.Contains(err.Error(), InvalidHandleErrorMessage) { logger.Debugf("failed to get console mode: %v", err) } else { logger.Errorf("failed to get console mode: %v", err) } return false } if err := windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err != nil { logger.Errorf("failed to set console mode: %v", err) _ = windows.SetConsoleMode(handle, mode) return false } return true } // ConsoleState stores the console mode for all standard handles so it can be restored // after subprocess execution. Subprocesses on Windows can modify the console mode, // which breaks ANSI escape handling and stdin line-input for the parent process. // // Note: SaveConsoleState and Restore operate on global OS handles (os.Stdin/Stdout/Stderr) // without synchronization. This is practically safe for concurrent use in run --all // because all goroutines target the same mode values. However, it is not formally // synchronized via mutex — a goroutine that saves state after a subprocess has modified // the console could capture a corrupted baseline. Impact is cosmetic only (garbled ANSI // output, not data corruption). type ConsoleState struct { stdinMode, stdoutMode, stderrMode uint32 stdinOK, stdoutOK, stderrOK bool } // SaveConsoleState captures the current console mode for stdin, stdout, and stderr. func SaveConsoleState() ConsoleState { var s ConsoleState s.stdinOK = windows.GetConsoleMode(windows.Handle(os.Stdin.Fd()), &s.stdinMode) == nil s.stdoutOK = windows.GetConsoleMode(windows.Handle(os.Stdout.Fd()), &s.stdoutMode) == nil s.stderrOK = windows.GetConsoleMode(windows.Handle(os.Stderr.Fd()), &s.stderrMode) == nil return s } // Restore restores the saved console modes. func (s ConsoleState) Restore() { if s.stdinOK { _ = windows.SetConsoleMode(windows.Handle(os.Stdin.Fd()), s.stdinMode) } if s.stdoutOK { _ = windows.SetConsoleMode(windows.Handle(os.Stdout.Fd()), s.stdoutMode) } if s.stderrOK { _ = windows.SetConsoleMode(windows.Handle(os.Stderr.Fd()), s.stderrMode) } } // For windows, there is no concept of a pseudoTTY so we run as if there is no pseudoTTY. func runCommandWithPTY(logger log.Logger, cmd *exec.Cmd) error { logger.Debug("Running command without PTY") if err := cmd.Start(); err != nil { return errors.New(err) } return nil } ================================================ FILE: internal/os/exec/testdata/infinite_loop.bat ================================================ @echo off :loop sleep 0.1 goto loop ================================================ FILE: internal/os/exec/testdata/test_exit_code.bat ================================================ @echo off exit %1 ================================================ FILE: internal/os/exec/testdata/test_exit_code.sh ================================================ #!/usr/bin/env bash set -e exit $1 ================================================ FILE: internal/os/exec/testdata/test_graceful_shutdown.sh ================================================ #!/usr/bin/env bash set -e # This script traps SIGINT and exits with code 42 when received. # It exits with code 1 if terminated by SIGKILL (or any other unexpected termination). # This is used to verify that the graceful shutdown sends SIGINT rather than SIGKILL. trap 'exit 42' INT while true; do sleep 0.1; done ================================================ FILE: internal/os/exec/testdata/test_sigint_multiple.sh ================================================ #!/usr/bin/env bash set -e INT_REQUIRED=$1 INT_COUNTER=0 trap int_handler INT function int_handler() { INT_COUNTER=$((INT_COUNTER + 1)) } while [[ $INT_COUNTER -lt $INT_REQUIRED ]] do sleep 0.1 done exit "$INT_COUNTER" ================================================ FILE: internal/os/exec/testdata/test_sigint_wait.sh ================================================ #!/usr/bin/env bash set -e WAIT_TIME=$1 trap int_handler INT function int_handler() { sleep "$WAIT_TIME" exit "$WAIT_TIME" } while true; do sleep 0.1; done ================================================ FILE: internal/os/signal/context_canceled.go ================================================ package signal import ( "context" "errors" "os" ) // ContextCanceledError contains a signal to pass through when the context is cancelled. type ContextCanceledError struct { Signal os.Signal } // SignalFromContext extracts the signal that caused the context cancellation, if any. // Returns nil if the context was not cancelled due to a signal. func SignalFromContext(ctx context.Context) os.Signal { cause := context.Cause(ctx) if cause == nil { return nil } var canceledErr *ContextCanceledError if errors.As(cause, &canceledErr) && canceledErr.Signal != nil { return canceledErr.Signal } return nil } // NewContextCanceledError returns a new `ContextCanceledError` instance. func NewContextCanceledError(sig os.Signal) *ContextCanceledError { return &ContextCanceledError{Signal: sig} } // Error implements the `Error` method. func (ContextCanceledError) Error() string { return context.Canceled.Error() } // Unwrap implements the `Unwrap` method. func (ContextCanceledError) Unwrap() error { return context.Canceled } ================================================ FILE: internal/os/signal/signal.go ================================================ // Package signal provides convenience methods for intercepting and handling OS signals. package signal import ( "context" "os" "os/signal" ) // NotifyFunc is a callback function for Notifier. type NotifyFunc func(sig os.Signal) // Notifier registers a handler for receiving signals from the OS. // When signal is receiving, it calls the given callback func `notifyFn`. func Notifier(notifyFn NotifyFunc, trackSignals ...os.Signal) { NotifierWithContext(context.Background(), notifyFn, trackSignals...) } // NotifierWithContext does the same as `Notifier`, but if the given `ctx` becomes `Done`, the notification is stopped. func NotifierWithContext(ctx context.Context, notifyFn NotifyFunc, trackSignals ...os.Signal) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, trackSignals...) go func() { for { select { case <-ctx.Done(): signal.Stop(sigCh) close(sigCh) return case sig, ok := <-sigCh: if !ok { return } notifyFn(sig) } } }() } ================================================ FILE: internal/os/signal/signal_unix.go ================================================ //go:build !windows // +build !windows package signal import ( "os" "syscall" ) // InterruptSignal is an interrupt signal. var InterruptSignal = syscall.SIGINT //nolint:gochecknoglobals // InterruptSignals contains a list of signals that are treated as interrupts. var InterruptSignals = []os.Signal{syscall.SIGTERM, syscall.SIGINT} //nolint:gochecknoglobals ================================================ FILE: internal/os/signal/signal_windows.go ================================================ //go:build windows // +build windows package signal import ( "os" ) // InterruptSignal is an interrupt signal. var InterruptSignal os.Signal = nil // InterruptSignals contains a list of signals that are treated as interrupts. var InterruptSignals []os.Signal = []os.Signal{} ================================================ FILE: internal/os/stdout/stdout.go ================================================ // Package stdout provides utilities for working with stdout. package stdout import "os" // IsRedirected returns true if the stdout is redirected. func IsRedirected() bool { stat, err := os.Stdout.Stat() if err != nil { return false } return (stat.Mode() & os.ModeCharDevice) == 0 } ================================================ FILE: internal/prepare/prepare.go ================================================ // Package prepare provides functionality to prepare downloaded OpenTofu/Terraform source code // for use with Terragrunt. This includes reading and parsing Terragrunt configuration, fetching // credentials, downloading source code, generating configuration files, and initializing the // OpenTofu/Terraform working directory. // // The preparation process follows a sequence of stages: // 1. PrepareConfig - Reads configuration and fetches credentials // 2. PrepareSource - Downloads terraform source if specified // 3. PrepareGenerate - Generates configuration files (generate blocks and remote_state) // 4. PrepareInputsAsEnvVars - Sets inputs as environment variables // 5. PrepareInit - Runs terraform init if needed package prepare import ( "context" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/amazonsts" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Config holds the result of preparing a terragrunt configuration. type Config struct { Cfg *config.TerragruntConfig Opts *options.TerragruntOptions } // PrepareConfig reads and parses the terragrunt configuration, fetches credentials, // and performs version constraint checks. This is the first stage of preparation. func PrepareConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (*Config, error) { // We need to get the credentials from auth-provider-cmd at the very beginning, // since the locals block may contain `get_aws_account_id()` func. credsGetter := creds.NewGetter() if err := credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, opts.Env, externalcmd.NewProvider(l, opts.AuthProviderCmd, configbridge.ShellRunOptsFromOpts(opts))); err != nil { return nil, err } ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) terragruntConfig, err := config.ReadTerragruntConfig(ctx, l, pctx, pctx.ParserOptions) if err != nil { return nil, err } return &Config{ Cfg: terragruntConfig, Opts: opts, }, nil } // PrepareSource downloads terraform source if specified in the configuration. // It requires PrepareConfig to have been called first. func PrepareSource( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, r *report.Report, ) (*options.TerragruntOptions, error) { engine, err := cfg.EngineOptions() if err != nil { return nil, err } opts.EngineConfig = engine errConfig, err := cfg.ErrorsConfig() if err != nil { return nil, err } // Only overwrite when the config actually defines error rules; // otherwise preserve the built-in default retryable errors. if errConfig != nil { opts.Errors = errConfig } runCfg := cfg.ToRunConfig(l) l, optsClone, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return nil, err } optsClone.TerraformCommand = run.CommandNameTerragruntReadConfig if err = optsClone.RunWithErrorHandling(ctx, l, r, func() error { return run.ProcessHooks(ctx, l, runCfg.Terraform.AfterHooks, configbridge.NewRunOptions(optsClone), runCfg, nil, r) }); err != nil { return nil, err } // We merge the OriginalIAMRoleOptions into the one from the config, because the CLI passed IAMRoleOptions has // precedence. opts.IAMRoleOptions = iam.MergeRoleOptions( cfg.GetIAMRoleOptions(), opts.OriginalIAMRoleOptions, ) credsGetter := creds.NewGetter() if err = opts.RunWithErrorHandling(ctx, l, r, func() error { return credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env)) }); err != nil { return nil, err } _, defaultDownloadDir := util.DefaultWorkingAndDownloadDirs(opts.TerragruntConfigPath) // if the download dir hasn't been changed from default, and is set in the config, // then use it if opts.DownloadDir == defaultDownloadDir && runCfg.DownloadDir != "" { opts.DownloadDir = runCfg.DownloadDir } sourceURL, err := runcfg.GetTerraformSourceURL(opts.Source, opts.SourceMap, opts.OriginalTerragruntConfigPath, runCfg) if err != nil { return nil, err } runOpts := configbridge.NewRunOptions(opts) var updatedRunOpts *run.Options // Always download/copy source to cache directory for consistency. // When no source is specified, sourceURL will be "." (current directory). err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "download_terraform_source", map[string]any{ "sourceUrl": sourceURL, }, func(ctx context.Context) error { updatedRunOpts, err = run.DownloadTerraformSource(ctx, l, sourceURL, runOpts, runCfg, r) return err }) if err != nil { return nil, err } // DownloadTerraformSource returns *run.Options; sync the updated WorkingDir // back to a *options.TerragruntOptions clone for callers that expect that type. _, updatedTerragruntOptions, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return nil, err } updatedTerragruntOptions.WorkingDir = updatedRunOpts.WorkingDir return updatedTerragruntOptions, nil } // PrepareGenerate handles code generation configs, both generate blocks and generate attribute of remote_state. // It requires PrepareSource to have been called first. func PrepareGenerate(l log.Logger, opts *options.TerragruntOptions, cfg *runcfg.RunConfig) error { return run.GenerateConfig(l, configbridge.NewRunOptions(opts), cfg) } // PrepareInputsAsEnvVars sets terragrunt inputs as environment variables. // It requires PrepareGenerate to have been called first. func PrepareInputsAsEnvVars(l log.Logger, opts *options.TerragruntOptions, cfg *runcfg.RunConfig) error { runOpts := configbridge.NewRunOptions(opts) // Check for terraform code if err := run.CheckFolderContainsTerraformCode(runOpts); err != nil { return err } return run.SetTerragruntInputsAsEnvVars(l, runOpts, cfg) } // PrepareInit runs terraform init if needed. This is the final preparation stage. // It requires PrepareInputsAsEnvVars to have been called first. func PrepareInit( ctx context.Context, l log.Logger, originalOpts, opts *options.TerragruntOptions, cfg *runcfg.RunConfig, r *report.Report, ) error { runOpts := configbridge.NewRunOptions(opts) // Check for terraform code if err := run.CheckFolderContainsTerraformCode(runOpts); err != nil { return err } if err := run.SetTerragruntInputsAsEnvVars(l, runOpts, cfg); err != nil { return err } // Run terraform init via the non-init command preparation path return run.PrepareNonInitCommand(ctx, l, configbridge.NewRunOptions(originalOpts), runOpts, cfg, r) } ================================================ FILE: internal/providercache/options/options.go ================================================ // Package options groups provider-cache-specific configuration that is // resolved at startup and shared with the ProviderCache server and hook // functions. It lives in its own package so that both pkg/options and // internal/providercache can import it without creating a cycle. package options // DefaultRegistryNames is the default set of remote registries cached by the // Terragrunt Provider Cache server. var DefaultRegistryNames = []string{ "registry.terraform.io", "registry.opentofu.org", } // ProviderCacheOptions holds provider-cache-specific configuration that was // previously spread across several fields on TerragruntOptions. type ProviderCacheOptions struct { Dir string Hostname string Token string RegistryNames []string Port int Enabled bool } ================================================ FILE: internal/providercache/providercache.go ================================================ // Package providercache provides initialization of the Terragrunt provider caching server for caching OpenTofu providers. package providercache import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "maps" "github.com/google/uuid" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" pcoptions "github.com/gruntwork-io/terragrunt/internal/providercache/options" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tf/cache" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( // The paths to the automatically generated local CLI configs localCLIFilename = ".terraformrc" // The status returned when making a request to the caching provider. // It is needed to prevent further loading of providers by terraform, and at the same time make sure that the request was processed successfully. CacheProviderHTTPStatusCode = http.StatusLocked // Authentication type on the Terragrunt Provider Cache server. APIKeyAuth = "x-api-key" // Retry configuration for registry operations during cache warm-up registryRetryMaxAttempts = 3 registryRetrySleepInterval = 5 * time.Second ) var ( // httpStatusCacheProviderReg is a regular expression to determine the success result of the command `terraform init`. // The reg matches if the text contains "423 Locked", for example: // // - registry.terraform.io/hashicorp/template: could not query provider registry for registry.terraform.io/hashicorp/template: 423 Locked. // // It also will match cases where terminal window is small enough so that terraform splits output in multiple lines, like following: // // ╷ // │ Error: Failed to install provider // │ // │ Error while installing snowflake-labs/snowflake v0.89.0: could not query // │ provider registry for registry.terraform.io/snowflake-labs/snowflake: 423 // │ Locked // ╵ httpStatusCacheProviderReg = regexp.MustCompile(`(?smi)` + strconv.Itoa(CacheProviderHTTPStatusCode) + `.*` + http.StatusText(CacheProviderHTTPStatusCode)) // registryTimeoutPatterns matches transient network errors that should trigger retries registryTimeoutPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?s).*Client\.Timeout exceeded while awaiting headers.*`), regexp.MustCompile(`(?s).*TLS handshake timeout.*`), regexp.MustCompile(`(?s).*context deadline exceeded.*`), regexp.MustCompile(`(?s).*connection reset by peer.*`), regexp.MustCompile(`(?s).*tcp.*timeout.*`), } ) type ProviderCache struct { *cache.Server opts *pcoptions.ProviderCacheOptions cliCfg *cliconfig.Config providerService *services.ProviderService fs vfs.FS } // NewProviderCache creates a new ProviderCache with sensible defaults. // Use builder methods like WithFS() to customize the configuration. func NewProviderCache() *ProviderCache { return &ProviderCache{ fs: vfs.NewOSFS(), } } // WithFS sets the filesystem for file operations and returns the ProviderCache // for method chaining. If not called, defaults to the real OS filesystem. func (pc *ProviderCache) WithFS(fs vfs.FS) *ProviderCache { pc.fs = fs return pc } // FS returns the configured filesystem. func (pc *ProviderCache) FS() vfs.FS { return pc.fs } // Init initializes the ProviderCache with the given logger and options. // Call this after configuring the ProviderCache with builder methods. func (pc *ProviderCache) Init(l log.Logger, pcOpts *pcoptions.ProviderCacheOptions, rootWorkingDir string) error { pc.opts = pcOpts // ProviderCacheDir has the same file structure as terraform plugin_cache_dir. // https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache if pcOpts.Dir == "" { cacheDir, err := util.GetCacheDir() if err != nil { return fmt.Errorf("failed to get cache directory: %w", err) } pcOpts.Dir = filepath.Join(cacheDir, "providers") } if !filepath.IsAbs(pcOpts.Dir) { pcOpts.Dir = filepath.Join(rootWorkingDir, pcOpts.Dir) } pcOpts.Dir = filepath.Clean(pcOpts.Dir) if pcOpts.Token == "" { pcOpts.Token = uuid.New().String() } // Currently, the cache server only supports the `x-api-key` token. if !strings.HasPrefix(strings.ToLower(pcOpts.Token), APIKeyAuth+":") { pcOpts.Token = fmt.Sprintf("%s:%s", APIKeyAuth, pcOpts.Token) } // Pass filesystem to LoadUserConfig cliCfg, err := cliconfig.LoadUserConfig(cliconfig.WithFS(pc.FS())) if err != nil { return err } userProviderDir, err := cliconfig.UserProviderDir() if err != nil { return err } providerService := services.NewProviderService(pcOpts.Dir, userProviderDir, cliCfg.CredentialsSource(), l, services.WithFS(pc.FS())) proxyProviderHandler := handlers.NewProxyProviderHandler(l, cliCfg.CredentialsSource()) providerHandlers, err := handlers.NewProviderHandlers(cliCfg, l, pcOpts.RegistryNames) if err != nil { return errors.Errorf("creating provider handlers failed: %w", err) } cacheServer := cache.NewServer( cache.WithHostname(pcOpts.Hostname), cache.WithPort(pcOpts.Port), cache.WithToken(pcOpts.Token), cache.WithProviderService(providerService), cache.WithProviderHandlers(providerHandlers...), cache.WithProxyProviderHandler(proxyProviderHandler), cache.WithCacheProviderHTTPStatusCode(CacheProviderHTTPStatusCode), cache.WithLogger(l), ) pc.Server = cacheServer pc.cliCfg = cliCfg pc.providerService = providerService return nil } // InitServer creates and initializes a new ProviderCache with the given logger and options. // This is a convenience function that combines NewProviderCache() and Init(). func InitServer(l log.Logger, pcOpts *pcoptions.ProviderCacheOptions, rootWorkingDir string) (*ProviderCache, error) { pc := NewProviderCache() if err := pc.Init(l, pcOpts, rootWorkingDir); err != nil { return nil, err } return pc, nil } // TerraformCommandHook warms up the providers cache, creates `.terraform.lock.hcl` and runs the `tofu/terraform init` // command with using this cache. Used as a hook function that is called after running the target tofu/terraform command. // For example, if the target command is `tofu plan`, it will be intercepted before it is run in the `/shell` package, // then control will be passed to this function to init the working directory using cached providers. func (pc *ProviderCache) TerraformCommandHook( ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, args clihelper.Args, ) (*util.CmdOutput, error) { // To prevent a loop ctx = tf.ContextWithTerraformCommandHook(ctx, nil) cliConfigFilename := filepath.Join(tfOpts.ShellOptions.WorkingDir, localCLIFilename) var skipRunTargetCommand bool lockfilePath := filepath.Join(tfOpts.ShellOptions.WorkingDir, tf.TerraformLockFile) lockfileExists := util.FileExists(lockfilePath) // Use Hook only for the `terraform init` command, which can be run explicitly by the user or Terragrunt's `auto-init` feature. switch { case args.CommandName() == tf.CommandNameInit: // Provider caching for `terraform init` command. case args.CommandName() == tf.CommandNameProviders && args.SubCommandName() == tf.CommandNameLock: // Provider caching for `terraform providers lock` command. // If no lock file exists, Terragrunt generates it. // // If one already exists, // let `tofu/terraform providers lock` run against the filesystem mirror // so OpenTofu/Terraform manages the lock file itself. if !lockfileExists { skipRunTargetCommand = true } default: // skip cache creation for all other commands return tf.RunCommandWithOutput(ctx, l, tfOpts, args...) } env := pc.providerCacheEnvironment(tfOpts.ShellOptions.Env, tfOpts.TofuImplementation, cliConfigFilename) if output, err := pc.warmUpCache(ctx, l, tfOpts, cliConfigFilename, args, env, lockfileExists); err != nil { return output, err } if skipRunTargetCommand { return &util.CmdOutput{}, nil } return pc.runTerraformWithCache(ctx, l, tfOpts, cliConfigFilename, args, env) } func (pc *ProviderCache) warmUpCache( ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, cliConfigFilename string, args clihelper.Args, env map[string]string, lockfileExists bool, ) (*util.CmdOutput, error) { var ( cacheRequestID = uuid.New().String() commandsArgs = convertToMultipleCommandsByPlatforms(args) ) // Create terraform cli config file that enables provider caching and does not use provider cache dir if err := pc.createLocalCLIConfig(ctx, tfOpts.TofuImplementation, cliConfigFilename, cacheRequestID); err != nil { return nil, err } l.Infof("Caching terraform providers for %s", tfOpts.ShellOptions.WorkingDir) // Before each init, we warm up the global cache to ensure that all necessary providers are cached. // To do this we are using 'terraform providers lock' to force TF to request all the providers from our TG cache, and that's how we know what providers TF needs, and can load them into the cache. // It's low cost operation, because it does not cache the same provider twice, but only new previously non-existent providers. for _, args := range commandsArgs { if output, err := pc.runTerraformCommand(ctx, l, tfOpts, args, env); err != nil { return output, err } } caches, err := pc.providerService.WaitForCacheReady(cacheRequestID) if err != nil { return nil, err } providerConstraints, err := getproviders.ParseProviderConstraints(tfOpts.TofuImplementation, filepath.Dir(tfOpts.TerragruntConfigPath)) if err != nil { l.Debugf("Failed to parse provider constraints from %s: %v", filepath.Dir(tfOpts.TerragruntConfigPath), err) providerConstraints = make(getproviders.ProviderConstraints) } isUpgrade := tfOpts.TerraformCliArgs != nil && tfOpts.TerraformCliArgs.Contains("-upgrade") // If a lock file already existed before this run, skip writing to it — let // OpenTofu/Terraform verify and manage the lock file during the actual init. if lockfileExists && !isUpgrade { l.Debugf("Skipping lock file update: %s already exists, letting OpenTofu/Terraform manage it", filepath.Join(tfOpts.ShellOptions.WorkingDir, tf.TerraformLockFile)) return nil, nil } for _, provider := range caches { if providerCache, ok := provider.(*services.ProviderCache); ok { providerAddr := provider.Address() if constraint, exists := providerConstraints[providerAddr]; exists { providerCache.Provider.OriginalConstraints = constraint l.Debugf("Applied constraint %s to provider %s", constraint, providerAddr) } else { l.Debugf("No constraint found for provider %s", providerAddr) } } } err = getproviders.UpdateLockfile(ctx, tfOpts.ShellOptions.WorkingDir, caches) if err != nil { return nil, err } // For upgrade scenarios where no providers were newly cached, we still need to update // the lock file if module constraints have changed. This only happens during upgrades. if len(caches) == 0 && len(providerConstraints) > 0 && isUpgrade { l.Debugf("No new providers cached, but constraints exist. Updating lock file constraints for upgrade scenario.") err = getproviders.UpdateLockfileConstraints(ctx, tfOpts.ShellOptions.WorkingDir, providerConstraints) } return nil, err } func (pc *ProviderCache) runTerraformWithCache( ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, cliConfigFilename string, args clihelper.Args, env map[string]string, ) (*util.CmdOutput, error) { // Create terraform cli config file that uses provider cache dir if err := pc.createLocalCLIConfig(ctx, tfOpts.TofuImplementation, cliConfigFilename, ""); err != nil { return nil, err } shellOpts := *tfOpts.ShellOptions // shallow copy shellOpts.Env = env newTFOpts := &tf.TFOptions{ JSONLogFormat: tfOpts.JSONLogFormat, OriginalTerragruntConfigPath: tfOpts.OriginalTerragruntConfigPath, TerragruntConfigPath: tfOpts.TerragruntConfigPath, TofuImplementation: tfOpts.TofuImplementation, TerraformCliArgs: tfOpts.TerraformCliArgs, ShellOptions: &shellOpts, } return tf.RunCommandWithOutput(ctx, l, newTFOpts, args...) } // createLocalCLIConfig creates a local CLI config that merges the default/user configuration with our Provider Cache configuration. // We don't want to use Terraform's `plugin_cache_dir` feature because the cache is populated by our Terragrunt Provider cache server, and to make sure that no Terraform process ever overwrites the global cache, we clear this value. // In order to force Terraform to queries our cache server instead of the original one, we use the section below. // https://github.com/hashicorp/terraform/issues/28309 (officially undocumented) // // host "registry.terraform.io" { // services = { // "providers.v1" = "http://localhost:5758/v1/providers/registry.terraform.io/", // } // } // // In order to force Terraform to create symlinks from the provider cache instead of downloading large binary files, we use the section below. // https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation // // provider_installation { // filesystem_mirror { // path = "/path/to/the/provider/cache" // include = ["example.com/*/*"] // } // direct { // exclude = ["example.com/*/*"] // } // } // // This func doesn't change the default CLI config file, only creates a new one at the given path `filename`. Ultimately, we can assign this path to `TF_CLI_CONFIG_FILE`. // // It creates two types of configuration depending on the `cacheRequestID` variable set. // 1. If `cacheRequestID` is set, `terraform init` does _not_ use the provider cache directory, the cache server creates a cache for requested providers and returns HTTP status 423. Since for each module we create the CLI config, using `cacheRequestID` we have the opportunity later retrieve from the cache server exactly those cached providers that were requested by `terraform init` using this configuration. // 2. If `cacheRequestID` is empty, 'terraform init` uses provider cache directory, the cache server acts as a proxy. func (pc *ProviderCache) createLocalCLIConfig(ctx context.Context, implementation tfimpl.Type, filename string, cacheRequestID string) error { cfg := pc.cliCfg.Clone() cfg.PluginCacheDir = "" // Filter registries based on OpenTofu or Terraform implementation to avoid contacting unnecessary registries filteredRegistryNames := filterRegistriesByImplementation( pc.opts.RegistryNames, implementation, ) var providerInstallationIncludes = make([]string, 0, len(filteredRegistryNames)) for _, registryName := range filteredRegistryNames { providerInstallationIncludes = append(providerInstallationIncludes, registryName+"/*/*") apiURLs, err := pc.DiscoveryURL(ctx, registryName) if err != nil { return err } cfg.AddHost(registryName, map[string]string{ "providers.v1": fmt.Sprintf("%s/%s/%s/", pc.ProviderController.URL(), cacheRequestID, registryName), // Since Terragrunt Provider Cache only caches providers, we need to route module requests to the original registry. "modules.v1": ResolveModulesURL(registryName, apiURLs.ModulesV1), }) } if cacheRequestID == "" { cfg.AddProviderInstallationMethods( cliconfig.NewProviderInstallationFilesystemMirror(pc.opts.Dir, providerInstallationIncludes, nil), ) } else { cfg.ProviderInstallation = nil } cfg.AddProviderInstallationMethods( cliconfig.NewProviderInstallationDirect(nil, nil), ) // Use VFS for directory operations fs := pc.FS() cfgDir := filepath.Dir(filename) cfgDirExists, err := vfs.FileExists(fs, cfgDir) if err != nil { return errors.New(err) } if !cfgDirExists { if err := fs.MkdirAll(cfgDir, os.ModePerm); err != nil { return errors.New(err) } } return cfg.Save(filename) } // isRegistryTimeoutError checks if the error output matches known transient registry timeout patterns func isRegistryTimeoutError(output []byte) bool { return slices.ContainsFunc(registryTimeoutPatterns, func(pattern *regexp.Regexp) bool { return pattern.Match(output) }) } func (pc *ProviderCache) runTerraformCommand(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, args []string, envs map[string]string) (*util.CmdOutput, error) { // add -no-color flag to args if it was set in Terragrunt arguments if tfOpts.TerraformCliArgs != nil && tfOpts.TerraformCliArgs.Contains(tf.FlagNameNoColor) && !slices.Contains(args, tf.FlagNameNoColor) { args = append(args, tf.FlagNameNoColor) } shellOpts := *tfOpts.ShellOptions // shallow copy shellOpts.Writers.Writer = io.Discard shellOpts.Env = envs newCliArgs := iacargs.New(args...) newTFOpts := &tf.TFOptions{ JSONLogFormat: tfOpts.JSONLogFormat, OriginalTerragruntConfigPath: tfOpts.OriginalTerragruntConfigPath, TerragruntConfigPath: tfOpts.TerragruntConfigPath, TofuImplementation: tfOpts.TofuImplementation, TerraformCliArgs: newCliArgs, ShellOptions: &shellOpts, } var finalOutput *util.CmdOutput err := util.DoWithRetry( ctx, "Running terraform providers lock", registryRetryMaxAttempts, registryRetrySleepInterval, l, log.DebugLevel, func(ctx context.Context) error { errWriter := util.NewTrapWriter(tfOpts.ShellOptions.Writers.ErrWriter) shellOpts.Writers.ErrWriter = errWriter output, cmdErr := tf.RunCommandWithOutput(ctx, l, newTFOpts, newCliArgs.Slice()...) finalOutput = output // If the OpenTofu/Terraform error matches `httpStatusCacheProviderReg` (423 Locked), // it means success - the cache recorded the request if cmdErr != nil && httpStatusCacheProviderReg.Match(output.Stderr.Bytes()) { return nil } if cmdErr != nil { if isRegistryTimeoutError(output.Stderr.Bytes()) { return cmdErr } err := errWriter.Flush() if err != nil { l.Warnf("Failed to flush stderr: %v", err) } return util.FatalError{Underlying: cmdErr} } if flushErr := errWriter.Flush(); flushErr != nil { return util.FatalError{Underlying: flushErr} } return nil }, ) if err != nil { // Unwrap FatalError to return the original error var fatalErr util.FatalError if errors.As(err, &fatalErr) { return finalOutput, fatalErr.Underlying } return finalOutput, err } return finalOutput, nil } // providerCacheEnvironment returns TF_* name/value ENVs, which we use to force terraform processes to make requests through our cache server (proxy) instead of making direct requests to the origin servers. func (pc *ProviderCache) providerCacheEnvironment(env map[string]string, implementation tfimpl.Type, cliConfigFile string) map[string]string { // make copy + ensure non-nil envs := make(map[string]string, len(env)) maps.Copy(envs, env) // Filter registries based on OpenTofu or Terraform implementation to avoid setting env vars for unnecessary registries filteredRegistryNames := filterRegistriesByImplementation( pc.opts.RegistryNames, implementation, ) for _, registryName := range filteredRegistryNames { envName := fmt.Sprintf(tf.EnvNameTFTokenFmt, strings.ReplaceAll(registryName, ".", "_")) // delete existing key case insensitive for key := range envs { if strings.EqualFold(key, envName) { delete(envs, key) } } // We use `TF_TOKEN_*` for authentication with our private registry (cache server). // https://developer.hashicorp.com/terraform/cli/config/config-file#environment-variable-credentials envs[envName] = pc.opts.Token } // By using `TF_CLI_CONFIG_FILE` we force terraform to use our auto-generated cli configuration file. // https://developer.hashicorp.com/terraform/cli/config/environment-variables#tf_cli_config_file envs[tf.EnvNameTFCLIConfigFile] = cliConfigFile // Clear this `TF_PLUGIN_CACHE_DIR` value since we are using our own caching mechanism. // https://developer.hashicorp.com/terraform/cli/config/environment-variables#tf_plugin_cache_dir envs[tf.EnvNameTFPluginCacheDir] = "" return envs } // convertToMultipleCommandsByPlatforms converts `providers lock -platform=.. -platform=..` command into multiple commands that include only one platform. // for example: // `providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=freebsd_amd64` // to // `providers lock -platform=linux_amd64`, // `providers lock -platform=darwin_arm64`, // `providers lock -platform=freebsd_amd64` func convertToMultipleCommandsByPlatforms(args []string) [][]string { var ( filteredArgs = make([]string, 0, len(args)) platformArgs = make([]string, 0, len(args)) ) for _, arg := range args { if strings.HasPrefix(arg, tf.FlagNamePlatform) { platformArgs = append(platformArgs, arg) } else { filteredArgs = append(filteredArgs, arg) } } if len(platformArgs) == 0 { return [][]string{args} } var commandsArgs = make([][]string, 0, len(platformArgs)) for _, platformArg := range platformArgs { var commandArgs = make([]string, len(filteredArgs), len(filteredArgs)+1) copy(commandArgs, filteredArgs) commandsArgs = append(commandsArgs, append(commandArgs, platformArg)) } return commandsArgs } // filterRegistriesByImplementation filters registry names based on the Terraform implementation being used. // If the registry names match the default registries (both registry.terraform.io and registry.opentofu.org), // it filters them based on the implementation: // - OpenTofuImpl: returns only registry.opentofu.org // - TerraformImpl: returns only registry.terraform.io // - UnknownImpl: returns both (backward compatibility) // // If the user has explicitly set registry names (don't match defaults), returns them as-is. func filterRegistriesByImplementation(registryNames []string, implementation tfimpl.Type) []string { // Default registries in the same order as defined in options/options.go defaultRegistries := []string{ "registry.terraform.io", "registry.opentofu.org", } // Check if registry names match defaults exactly (order-independent) if len(registryNames) == len(defaultRegistries) { matchesDefault := true for _, defaultReg := range defaultRegistries { if !slices.Contains(registryNames, defaultReg) { matchesDefault = false break } } // If matches defaults, filter based on implementation if matchesDefault { switch implementation { case tfimpl.OpenTofu: return []string{"registry.opentofu.org"} case tfimpl.Terraform: return []string{"registry.terraform.io"} case tfimpl.Unknown: // Backward compatibility: use both registries if implementation is unknown return registryNames default: // Unknown implementation type, return as-is return registryNames } } } // User explicitly set registry names, return as-is return registryNames } // ResolveModulesURL resolves the modules.v1 URL from registry discovery. // If the URL is already absolute (contains "://"), it is returned as-is. // Otherwise, it is treated as a relative path and combined with the registry name. func ResolveModulesURL(registryName, modulesV1 string) string { if strings.Contains(modulesV1, "://") { return modulesV1 } return fmt.Sprintf("https://%s%s", registryName, modulesV1) } ================================================ FILE: internal/providercache/providercache_test.go ================================================ package providercache_test import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "runtime" "testing" "github.com/google/uuid" "github.com/gruntwork-io/terragrunt/internal/providercache" pcoptions "github.com/gruntwork-io/terragrunt/internal/providercache/options" "github.com/gruntwork-io/terragrunt/internal/tf/cache" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" ) func createFakeProvider(t *testing.T, cacheDir, relativePath string) string { t.Helper() err := os.MkdirAll(filepath.Join(cacheDir, filepath.Dir(relativePath)), os.ModePerm) require.NoError(t, err) file, err := os.Create(filepath.Join(cacheDir, relativePath)) require.NoError(t, err) defer file.Close() err = file.Sync() require.NoError(t, err) return relativePath } func TestProviderCache(t *testing.T) { t.Parallel() token := fmt.Sprintf("%s:%s", providercache.APIKeyAuth, uuid.New().String()) providerCacheDir := helpers.TmpDirWOSymlinks(t) pluginCacheDir := helpers.TmpDirWOSymlinks(t) opts := []cache.Option{cache.WithToken(token), cache.WithCacheProviderHTTPStatusCode(providercache.CacheProviderHTTPStatusCode)} testCases := []struct { expectedBodyReg *regexp.Regexp fullURLPath string relURLPath string expectedCachePath string opts []cache.Option expectedStatusCode int }{ { opts: opts, fullURLPath: "/.well-known/terraform.json", expectedStatusCode: http.StatusOK, expectedBodyReg: regexp.MustCompile(regexp.QuoteMeta(`{"providers.v1":"/v1/providers"}`)), }, { opts: append(opts, cache.WithToken("")), relURLPath: "/cache/registry.terraform.io/hashicorp/aws/versions", expectedStatusCode: http.StatusUnauthorized, }, { opts: opts, relURLPath: "/cache/registry.terraform.io/hashicorp/aws/versions", expectedStatusCode: http.StatusOK, expectedBodyReg: regexp.MustCompile(regexp.QuoteMeta(`"version":"5.36.0","protocols":["5.0"],"platforms"`)), }, { opts: opts, relURLPath: "/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64", expectedStatusCode: http.StatusLocked, expectedCachePath: "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5", }, { opts: opts, relURLPath: "/cache/registry.terraform.io/hashicorp/template/2.2.0/download/linux/amd64", expectedStatusCode: http.StatusLocked, expectedCachePath: "registry.terraform.io/hashicorp/template/2.2.0/linux_amd64/terraform-provider-template_v2.2.0_x4", }, { opts: opts, relURLPath: fmt.Sprintf("/cache/registry.terraform.io/hashicorp/template/1234.5678.9/download/%s/%s", runtime.GOOS, runtime.GOARCH), expectedStatusCode: http.StatusLocked, expectedCachePath: createFakeProvider(t, pluginCacheDir, fmt.Sprintf("registry.terraform.io/hashicorp/template/1234.5678.9/%s_%s/terraform-provider-template_1234.5678.9_x5", runtime.GOOS, runtime.GOARCH)), }, { opts: opts, relURLPath: "//registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64", expectedStatusCode: http.StatusOK, expectedBodyReg: regexp.MustCompile(`\{.*` + regexp.QuoteMeta(`"download_url":"http://127.0.0.1:`) + `\d+` + regexp.QuoteMeta(`/downloads/releases.hashicorp.com/terraform-provider-aws/5.36.0/terraform-provider-aws_5.36.0_darwin_arm64.zip"`) + `.*\}`), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() // TODO: Remove this once we can invest time in figuring out why this test is so flaky. // // It's a pain, but it's not worth the time to fix it. maxRetries := 3 var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { t.Logf("Retry attempt %d/%d for test case %d", attempt, maxRetries, i) } // Create a new context for each test case to avoid interference // //nolint:usetesting ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() errGroup, ctx := errgroup.WithContext(ctx) logger := logger.CreateLogger() providerService := services.NewProviderService(providerCacheDir, pluginCacheDir, nil, logger) providerHandler := handlers.NewDirectProviderHandler(logger, new(cliconfig.ProviderInstallationDirect), nil) proxyProviderHandler := handlers.NewProxyProviderHandler(logger, nil) tc.opts = append(tc.opts, cache.WithProviderService(providerService), cache.WithProviderHandlers(providerHandler), cache.WithProxyProviderHandler(proxyProviderHandler), ) server := cache.NewServer(tc.opts...) ln, err := server.Listen(t.Context()) if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } defer ln.Close() errGroup.Go(func() error { return server.Run(ctx, ln) }) urlPath := server.ProviderController.URL() urlPath.Path += tc.relURLPath if tc.fullURLPath != "" { urlPath.Path = tc.fullURLPath } req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlPath.String(), nil) if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } defer resp.Body.Close() if resp.StatusCode != tc.expectedStatusCode { lastErr = fmt.Errorf("expected status code %d, got %d", tc.expectedStatusCode, resp.StatusCode) if attempt < maxRetries { continue } assert.Equal(t, tc.expectedStatusCode, resp.StatusCode) } if tc.expectedBodyReg != nil { body, err := io.ReadAll(resp.Body) if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } if !tc.expectedBodyReg.MatchString(string(body)) { lastErr = fmt.Errorf("body did not match expected regex: %s", tc.expectedBodyReg.String()) if attempt < maxRetries { continue } assert.Regexp(t, tc.expectedBodyReg, string(body)) } } // Skip WaitForCacheReady for unauthorized test cases since they don't trigger background operations, // and we cancel context at the end of the test. if tc.expectedStatusCode != http.StatusUnauthorized { _, err = providerService.WaitForCacheReady("") if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } } if tc.expectedCachePath != "" { if !assert.FileExists(t, filepath.Join(providerCacheDir, tc.expectedCachePath)) { lastErr = fmt.Errorf("expected cache file does not exist: %s", tc.expectedCachePath) if attempt < maxRetries { continue } } } cancel() err = errGroup.Wait() if err != nil { lastErr = err if attempt < maxRetries { continue } require.NoError(t, err) } return } t.Fatalf("Test case %d failed after %d attempts. Last error: %v", i, maxRetries, lastErr) }) } } func TestProviderCacheHomeless(t *testing.T) { cacheDir := helpers.TmpDirWOSymlinks(t) t.Setenv("HOME", "") require.NoError(t, os.Unsetenv("HOME")) t.Setenv("XDG_CACHE_HOME", "") require.NoError(t, os.Unsetenv("XDG_CACHE_HOME")) _, err := providercache.InitServer(logger.CreateLogger(), &pcoptions.ProviderCacheOptions{ Dir: cacheDir, }, "") require.NoError(t, err, "ProviderCache shouldn't read HOME environment variable") } func TestProviderCacheWithProviderCacheDir(t *testing.T) { t.Parallel() t.Run("NoNewDirectoriesAtHOME", func(t *testing.T) { t.Parallel() // Use in-memory filesystem to isolate file operations from the real filesystem. // This ensures InitServer doesn't create any directories on the real filesystem // since all file operations are routed through the VFS. memFs := vfs.NewMemMapFS() cacheDir := "/test/provider-cache" server := providercache.NewProviderCache().WithFS(memFs) err := server.Init( logger.CreateLogger(), &pcoptions.ProviderCacheOptions{ Dir: cacheDir, }, "", ) require.NoError(t, err) // With VFS, all file operations go through the in-memory filesystem, // so no directories should be created on the real filesystem at all. // We can verify the VFS is being used by checking it's not empty or // by the fact that no errors occurred despite using fake paths. }) t.Run("InitServerWithVFS", func(t *testing.T) { t.Parallel() memFs := vfs.NewMemMapFS() cacheDir := "/vfs/provider-cache" server := providercache.NewProviderCache().WithFS(memFs) err := server.Init( logger.CreateLogger(), &pcoptions.ProviderCacheOptions{ Dir: cacheDir, }, "", ) require.NoError(t, err) require.NotNil(t, server, "Init should return a valid server when using VFS") }) } ================================================ FILE: internal/providercache/resolve_modules_url_test.go ================================================ package providercache_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/providercache" "github.com/stretchr/testify/assert" ) func TestResolveModulesURL(t *testing.T) { t.Parallel() testCases := []struct { name string registryName string modulesV1 string expected string }{ { name: "relative path", registryName: "registry.terraform.io", modulesV1: "/v1/modules", expected: "https://registry.terraform.io/v1/modules", }, { name: "relative path with trailing slash", registryName: "private.registry.com", modulesV1: "/custom/modules/", expected: "https://private.registry.com/custom/modules/", }, { name: "absolute URL same host", registryName: "packages.syncron.team", modulesV1: "https://packages.syncron.team/somepath/modules/", expected: "https://packages.syncron.team/somepath/modules/", }, { name: "absolute URL different host", registryName: "registry.example.com", modulesV1: "https://other.host.com/modules/v1/", expected: "https://other.host.com/modules/v1/", }, { name: "absolute URL with http scheme", registryName: "registry.example.com", modulesV1: "http://internal.host.com/modules/", expected: "http://internal.host.com/modules/", }, { name: "empty path", registryName: "registry.terraform.io", modulesV1: "", expected: "https://registry.terraform.io", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := providercache.ResolveModulesURL(tc.registryName, tc.modulesV1) assert.Equal(t, tc.expected, result) }) } } ================================================ FILE: internal/queue/queue.go ================================================ // Package queue provides a run queue implementation. // The queue is a double-ended queue (deque) that allows for efficient adding and removing of elements from both ends. // The queue is used to manage the order of Terragrunt runs. // // The algorithm for populating the queue is as follows: // 1. Given a list of discovered configurations, start with an empty queue. // 2. Sort configurations alphabetically to ensure deterministic ordering of independent items. // 3. For each discovered configuration: // a. If the configuration has no dependencies, append it to the queue. // b. Otherwise, find the position after its last dependency. // c. Among items that depend on the same dependency, maintain alphabetical order. // // The resulting queue will have: // - Configurations with no dependencies at the front // - Configurations with dependents are ordered after their dependencies // - Alphabetical ordering only between items that share the same dependencies // // During operations like applies, entries will be dequeued from the front of the queue and run. // During operations like destroys, entries will be dequeued from the back of the queue and run. // This ensures that dependencies are satisfied in both cases: // - For applies: Dependencies (front) are run before their dependents (back) // - For destroys: Dependents (back) are run before their dependencies (front) package queue import ( "errors" "slices" "sort" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Entry represents a node in the execution queue/DAG. Each Entry corresponds to a single Terragrunt configuration // and tracks its execution status and relationships to other entries in the queue. type Entry struct { // Component is the Terragrunt configuration associated with this entry. It contains all metadata about the unit/stack, // including its path, dependencies, and discovery context (such as the command being run). Component component.Component // Status represents the current lifecycle state of this entry in the queue. It tracks whether the entry is pending, // blocked, ready, running, succeeded, or failed. Status is updated as dependencies are resolved and as execution progresses. Status Status } // Status represents the lifecycle state of a task in the queue. type Status byte const ( StatusPending Status = iota StatusBlocked StatusUnsorted StatusReady StatusRunning StatusSucceeded StatusFailed StatusEarlyExit // Terminal status set on Entries in case of fail fast mode ) // UpdateBlocked updates the status of the entry to blocked, if it is blocked. // An entry is blocked if: // 1. It is an "up" command (none of destroy, apply -destroy or plan -destroy) // and it has dependencies that are not ready. // 2. It is a "down" command (destroy, apply -destroy or plan -destroy) // and it has dependents that are not ready. // // If the entry isn't blocked, then it is marked as unsorted, and is ready to be sorted. func (e *Entry) UpdateBlocked(entries Entries) { // If the entry is already ready, we can skip the rest of the logic. if e.Status == StatusReady { return } if e.IsUp() { for _, dep := range e.Component.Dependencies() { depEntry := entries.Entry(dep) if depEntry == nil { continue } if !depEntry.IsUp() { continue } if depEntry.Status != StatusReady { e.Status = StatusBlocked return } } e.Status = StatusUnsorted return } // If the entry is a "down" command, we need to check if all of its dependents are ready. for _, qEntry := range entries { if len(qEntry.Component.Dependencies()) == 0 { continue } if !slices.Contains(qEntry.Component.Dependencies(), e.Component) { continue } if qEntry.IsUp() { continue } if qEntry.Status != StatusReady { e.Status = StatusBlocked return } } e.Status = StatusUnsorted } // IsUp returns true if the entry is an "up" command. func (e *Entry) IsUp() bool { // If we don't have a discovery context, // we should assume the command is an "up" command. if e.Component.DiscoveryContext() == nil { return true } if e.Component.DiscoveryContext().Cmd == "destroy" { return false } if e.Component.DiscoveryContext().Cmd == "apply" && slices.Contains(e.Component.DiscoveryContext().Args, "-destroy") { return false } if e.Component.DiscoveryContext().Cmd == "plan" && slices.Contains(e.Component.DiscoveryContext().Args, "-destroy") { return false } return true } type Queue struct { // Entries is a list of entries in the queue. Entries Entries // mu is a mutex used to synchronize access to the queue. mu sync.RWMutex // FailFast, if set to true, causes the queue to fail fast if any entry fails. FailFast bool // IgnoreDependencyOrder, if set to true, causes the queue to ignore dependencies when fetching ready entries. // When enabled, GetReadyWithDependencies will return all entries with StatusReady, regardless of dependency status. IgnoreDependencyOrder bool // IgnoreDependencyErrors, if set to true, allows scheduling and running entries even if their // dependencies failed. Additionally, failures will not propagate EarlyExit to dependents/dependencies. IgnoreDependencyErrors bool } type Entries []*Entry // Entry returns a given entry from the queue. func (e Entries) Entry(cfg component.Component) *Entry { for _, entry := range e { if entry.Component.Path() == cfg.Path() { return entry } } return nil } // Components returns the queue components. func (q *Queue) Components() component.Components { result := make(component.Components, 0, len(q.Entries)) for _, entry := range q.Entries { result = append(result, entry.Component) } return result } // EntryByPath returns the entry with the given config path, or nil if not found. func (q *Queue) EntryByPath(path string) *Entry { q.mu.RLock() defer q.mu.RUnlock() return q.entryByPathUnsafe(path) } // entryByPathUnsafe returns the entry with the given config path without locking. // Should only be called when the caller already holds a lock. func (q *Queue) entryByPathUnsafe(path string) *Entry { for _, entry := range q.Entries { if entry.Component.Path() == path { return entry } } return nil } // NewQueue creates a new queue from a list of discovered configurations. // The queue is populated with the correct Terragrunt run order. // // Discovered configurations will be sorted based on two criteria: // // 1. The discovery context of the configuration: // - If the configuration is for an "up" command (none of destroy, apply -destroy or plan -destroy), // it will be inserted at the front of the queue, before its dependencies. // - Otherwise, it is considered a "down" command, and will be inserted at the back of the queue, // after its dependents. // // 2. The name of the configuration. Configurations of the same "level" are sorted alphabetically. // // Passing configurations that haven't been checked for cycles in their dependency graph is unsafe. // If any cycles are present, the queue construction will halt after N // iterations, where N is the number of discovered configs, and throw an error. func NewQueue(discovered component.Components) (*Queue, error) { if len(discovered) == 0 { return &Queue{ Entries: Entries{}, }, nil } // First, we need to take all the discovered configs // and assign them a status of pending. entries := make(Entries, 0, len(discovered)) for _, cfg := range discovered { entry := &Entry{ Component: cfg, Status: StatusPending, } entries = append(entries, entry) } q := &Queue{ Entries: entries, } // readyPending returns the index of the first pending entry if there is one, // or -1 if there are no pending entries. readyPending := func(entries Entries) int { // Next, we need to iterate through the entries // and check if any of them are blocked. for _, entry := range entries { entry.UpdateBlocked(entries) } // Next, we need to sort the entries by status and path. sort.SliceStable(entries, func(i, j int) bool { if entries[i].Status > entries[j].Status { return true } if entries[i].Status == StatusUnsorted && entries[j].Status == StatusUnsorted { return entries[i].Component.Path() < entries[j].Component.Path() } return false }) // Now, we can mark all unsorted entries as ready, // and check if all entries are ready. for idx, entry := range entries { if entry.Status == StatusUnsorted { entry.Status = StatusReady } if entry.Status != StatusReady { return idx } } return -1 } // We need to iterate through the entries until all entries are ready. // We can use the length of the entries as a safe upper bound for the number of iterations, // because a cycle-free graph has a maximum depth of N, where N is the number of discovered configs. maxIterations := len(entries) // We keep track of the index of the first pending entry // to save us from iterating through the entire list of entries // on each iteration. firstPending := 0 for range maxIterations { firstPending = readyPending(entries[firstPending:]) if firstPending == -1 { return q, nil } } return q, errors.New("cycle detected during queue construction") } // GetReadyWithDependencies returns all entries that are ready to run and have all dependencies completed (or no dependencies). func (q *Queue) GetReadyWithDependencies(l log.Logger) []*Entry { q.mu.RLock() defer q.mu.RUnlock() if q.IgnoreDependencyOrder { out := make([]*Entry, 0, len(q.Entries)) for _, e := range q.Entries { if e.Status == StatusReady { out = append(out, e) } } return out } out := make([]*Entry, 0, len(q.Entries)) for _, e := range q.Entries { if e.Status != StatusReady { continue } if e.IsUp() { if q.areDependenciesReadyUnsafe(l, e) { out = append(out, e) } continue } if q.areDependentsReadyUnsafe(e) { out = append(out, e) } } return out } // areDependenciesReadyUnsafe checks if all dependencies of an entry are ready for "up" commands. // For up commands, all dependencies must be in a succeeded state (or terminal if ignoring errors). // If a dependency is not in the queue, it is assumed to have existing state. // Should only be called when the caller already holds a read lock. func (q *Queue) areDependenciesReadyUnsafe(l log.Logger, e *Entry) bool { for _, dep := range e.Component.Dependencies() { depEntry := q.entryByPathUnsafe(dep.Path()) if depEntry == nil { l.Debugf("Dependency %s is not in queue, considering it ready", dep.Path()) continue } // When ignoring dependency errors, allow scheduling if dependencies are in a terminal state // (succeeded OR failed), not just succeeded if q.IgnoreDependencyErrors { if !isTerminal(depEntry.Status) { return false } continue } if depEntry.Status != StatusSucceeded { return false } } return true } // areDependentsReadyUnsafe checks if all dependents of an entry are ready for "down" commands. // For down commands, all dependents must be in a succeeded state (or terminal if ignoring errors). // Should only be called when the caller already holds a read lock. func (q *Queue) areDependentsReadyUnsafe(e *Entry) bool { for _, other := range q.Entries { if other == e || len(other.Component.Dependencies()) == 0 { continue } for _, dep := range other.Component.Dependencies() { if dep.Path() == e.Component.Path() { // When ignoring dependency errors, allow scheduling if dependents are in a terminal state // (succeeded OR failed), not just succeeded if q.IgnoreDependencyErrors { if !isTerminal(other.Status) { return false } continue } if other.Status != StatusSucceeded { return false } } } } return true } // SetEntryStatus safely sets the status of an entry with proper synchronization. // // If the entry is already in a terminal state (StatusSucceeded, StatusFailed, or StatusEarlyExit), // this operation is a no-op. This prevents race conditions where a concurrent success could // overwrite an early-exit status set by fail-fast mode. func (q *Queue) SetEntryStatus(e *Entry, status Status) { q.mu.Lock() defer q.mu.Unlock() if isTerminal(e.Status) { return } e.Status = status } // FailEntry marks the entry as failed and updates related entries if needed. // For up commands, this marks entries that come after this one as early exit. // For destroy/down commands, this marks entries that come before this one as early exit. // Use only for failure transitions. For other status changes, set Status directly. func (q *Queue) FailEntry(e *Entry) { q.mu.Lock() defer q.mu.Unlock() e.Status = StatusFailed // If this entry failed and has dependents/dependencies, we need to propagate the failure. if q.FailFast { for _, n := range q.Entries { if isTerminalOrRunning(n.Status) { continue } n.Status = StatusEarlyExit } return } // If ignoring dependency errors, do not propagate early exit to other entries. if q.IgnoreDependencyErrors { return } if e.IsUp() { q.earlyExitDependents(e) return } q.earlyExitDependencies(e) } // earlyExitDependents - Recursively mark all entries that are dependent on this one as early exit. func (q *Queue) earlyExitDependents(e *Entry) { for _, entry := range q.Entries { if len(entry.Component.Dependencies()) == 0 { continue } for _, dep := range entry.Component.Dependencies() { if dep.Path() == e.Component.Path() { if isTerminalOrRunning(entry.Status) { continue } entry.Status = StatusEarlyExit q.earlyExitDependents(entry) break } } } } // earlyExitDependencies - Recursively mark all entries that are dependencies on this one as early exit. func (q *Queue) earlyExitDependencies(e *Entry) { if len(e.Component.Dependencies()) == 0 { return } for _, dep := range e.Component.Dependencies() { depEntry := q.entryByPathUnsafe(dep.Path()) if depEntry == nil { continue } if isTerminalOrRunning(depEntry.Status) { continue } depEntry.Status = StatusEarlyExit q.earlyExitDependencies(depEntry) } } // Finished checks if all entries in the queue are in a terminal state (i.e., not pending, blocked, ready, or running). func (q *Queue) Finished() bool { q.mu.RLock() defer q.mu.RUnlock() for _, e := range q.Entries { if !isTerminal(e.Status) { return false } } return true } // RemainingDeps Helper to calculate remaining dependencies for an entry. func (q *Queue) RemainingDeps(e *Entry) int { if e.Component == nil || len(e.Component.Dependencies()) == 0 { return 0 } q.mu.RLock() defer q.mu.RUnlock() count := 0 for _, dep := range e.Component.Dependencies() { depEntry := q.entryByPathUnsafe(dep.Path()) if depEntry == nil || depEntry.Status != StatusSucceeded { count++ } } return count } // isTerminal returns true if the status is terminal. func isTerminal(status Status) bool { switch status { case StatusPending, StatusBlocked, StatusUnsorted, StatusReady, StatusRunning: return false case StatusSucceeded, StatusFailed, StatusEarlyExit: return true } return false } // isTerminalOrRunning returns true if the status is terminal or running. func isTerminalOrRunning(status Status) bool { return status == StatusRunning || isTerminal(status) } ================================================ FILE: internal/queue/queue_test.go ================================================ package queue_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNoDependenciesMaintainsAlphabeticalOrder(t *testing.T) { t.Parallel() // Create configs with no dependencies - should maintain alphabetical order at front configs := component.Components{ component.NewUnit("c"), component.NewUnit("a"), component.NewUnit("b"), } q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // Should be sorted alphabetically at front since none have dependencies assert.Equal(t, "a", entries[0].Component.Path()) assert.Equal(t, "b", entries[1].Component.Path()) assert.Equal(t, "c", entries[2].Component.Path()) } func TestDependenciesOrderedByDependencyLevel(t *testing.T) { t.Parallel() // Create configs with dependencies - should order by dependency level aCfg := component.NewUnit("a") bCfg := component.NewUnit("b") bCfg.AddDependency(aCfg) cCfg := component.NewUnit("c") cCfg.AddDependency(bCfg) configs := component.Components{aCfg, bCfg, cCfg} q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // 'a' has no deps so should be at front // 'b' depends on 'a' so should be after // 'c' depends on 'b' so should be at back assert.Equal(t, "a", entries[0].Component.Path()) assert.Equal(t, "b", entries[1].Component.Path()) assert.Equal(t, "c", entries[2].Component.Path()) } func TestComplexDagOrderedByDependencyLevelAndAlphabetically(t *testing.T) { t.Parallel() // Create a more complex dependency graph: // Create a more complex dependency graph: // A (no deps) // B (no deps) // C -> A // D -> A,B // E -> C // F -> C A := component.NewUnit("A") B := component.NewUnit("B") C := component.NewUnit("C") C.AddDependency(A) D := component.NewUnit("D") D.AddDependency(A) D.AddDependency(B) E := component.NewUnit("E") E.AddDependency(C) F := component.NewUnit("F") F.AddDependency(C) configs := component.Components{F, E, D, C, B, A} q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // Verify ordering by dependency level and alphabetically within levels: // Level 0 (no deps): A, B // Level 1 (depends on level 0): C, D // Level 2 (depends on level 1): E, F assert.Equal(t, "A", entries[0].Component.Path()) assert.Equal(t, "B", entries[1].Component.Path()) assert.Equal(t, "C", entries[2].Component.Path()) assert.Equal(t, "D", entries[3].Component.Path()) assert.Equal(t, "E", entries[4].Component.Path()) assert.Equal(t, "F", entries[5].Component.Path()) // Also verify relative ordering aIndex := findIndex(entries, "A") bIndex := findIndex(entries, "B") cIndex := findIndex(entries, "C") dIndex := findIndex(entries, "D") eIndex := findIndex(entries, "E") fIndex := findIndex(entries, "F") // Level 0 items should be before their dependents assert.Less(t, aIndex, cIndex, "A should come before C") assert.Less(t, aIndex, dIndex, "A should come before D") assert.Less(t, bIndex, dIndex, "B should come before D") // Level 1 items should be before their dependents assert.Less(t, cIndex, eIndex, "C should come before E") assert.Less(t, cIndex, fIndex, "C should come before F") } func TestDeterministicOrderingOfParallelDependencies(t *testing.T) { t.Parallel() // Create a graph with parallel dependencies that could be ordered multiple ways: // Create a graph with parallel dependencies that could be ordered multiple ways: // A (no deps) // B -> A // C -> A // D -> A A := component.NewUnit("A") B := component.NewUnit("B") B.AddDependency(A) C := component.NewUnit("C") C.AddDependency(A) D := component.NewUnit("D") D.AddDependency(A) configs := component.Components{D, C, B, A} // Run multiple times to verify deterministic ordering for range 5 { q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // A should be first (no deps) assert.Equal(t, "A", entries[0].Component.Path()) // B, C, D should maintain alphabetical order since they're all at the same level assert.Equal(t, "B", entries[1].Component.Path()) assert.Equal(t, "C", entries[2].Component.Path()) assert.Equal(t, "D", entries[3].Component.Path()) } } func TestDepthBasedOrderingVerification(t *testing.T) { t.Parallel() // Create a graph where depth matters: // Create a graph where depth matters: // A (no deps, depth 0) // B (no deps, depth 0) // C -> A (depth 1) // D -> B (depth 1) // E -> C,D (depth 2) A := component.NewUnit("A") B := component.NewUnit("B") C := component.NewUnit("C") C.AddDependency(A) D := component.NewUnit("D") D.AddDependency(B) E := component.NewUnit("E") E.AddDependency(C) E.AddDependency(D) configs := component.Components{E, D, C, B, A} q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // Verify that items are grouped by their depth levels // Level 0: A,B (no deps) // Level 1: C,D (depend on level 0) // Level 2: E (depends on level 1) // First verify the basic ordering assert.Len(t, entries, 5, "Should have all 5 entries") // Find indices aIndex := findIndex(entries, "A") bIndex := findIndex(entries, "B") cIndex := findIndex(entries, "C") dIndex := findIndex(entries, "D") eIndex := findIndex(entries, "E") // Level 0 items should be at the start (indices 0 or 1) assert.LessOrEqual(t, aIndex, 1, "A should be in first two positions") assert.LessOrEqual(t, bIndex, 1, "B should be in first two positions") // Level 1 items should be in the middle (indices 2 or 3) assert.True(t, cIndex >= 2 && cIndex <= 3, "C should be in middle positions") assert.True(t, dIndex >= 2 && dIndex <= 3, "D should be in middle positions") // Level 2 item should be at the end (index 4) assert.Equal(t, 4, eIndex, "E should be in last position") } func TestErrorHandlingCycle(t *testing.T) { t.Parallel() // Create a cycle: A -> B -> C -> A // Create a cycle: A -> B -> C -> A A := component.NewUnit("A") B := component.NewUnit("B") C := component.NewUnit("C") C.AddDependency(B) B.AddDependency(A) A.AddDependency(C) // Creates the cycle configs := component.Components{C, B, A} q, err := queue.NewQueue(configs) require.Error(t, err) assert.NotNil(t, q) } func TestErrorHandlingEmptyConfigList(t *testing.T) { t.Parallel() // Create an empty config list configs := component.Components{} q, err := queue.NewQueue(configs) require.NoError(t, err) assert.Empty(t, q.Entries) } // findIndex returns the index of the config with the given path in the slice func findIndex(entries queue.Entries, path string) int { for i, cfg := range entries { if cfg.Component.Path() == path { return i } } return -1 } func TestQueue_LinearDependencyExecution(t *testing.T) { t.Parallel() // A -> B -> C cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) // Initially, not all are terminal assert.False(t, q.Finished(), "Finished should be false at start") // Check that all entries are ready initially and in order A, B, C readyEntries := q.GetReadyWithDependencies(logger.CreateLogger()) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, queue.StatusReady, readyEntries[0].Status, "Entry %s should have StatusReady", readyEntries[0].Component.Path()) assert.Equal(t, "A", readyEntries[0].Component.Path(), "First ready entry should be A") // Mark A as running and complete it entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded assert.False(t, q.Finished(), "Finished should be false after A is done") readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) assert.Len(t, readyEntries, 1, "After A is done, only B should be ready") assert.Equal(t, "B", readyEntries[0].Component.Path(), "Second ready entry should be B") // Mark B as running and complete it entryB := readyEntries[0] entryB.Status = queue.StatusSucceeded assert.False(t, q.Finished(), "Finished should be false after B is done") readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) assert.Len(t, readyEntries, 1, "After B is done, only C should be ready") assert.Equal(t, "C", readyEntries[0].Component.Path(), "Third ready entry should be C") // Mark C as running and complete it entryC := readyEntries[0] entryC.Status = queue.StatusSucceeded // Now all should be terminal assert.True(t, q.Finished(), "Finished should be true after all succeeded") readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) assert.Empty(t, readyEntries, "After C is done, no entries should be ready") } func TestQueue_ParallelExecution(t *testing.T) { t.Parallel() // A // / \ // B C cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgA) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) // 1. Initially, only A should be ready readyEntries := q.GetReadyWithDependencies(logger.CreateLogger()) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, queue.StatusReady, readyEntries[0].Status, "Entry %s should have StatusReady", readyEntries[0].Component.Path()) assert.Equal(t, "A", readyEntries[0].Component.Path(), "First ready entry should be A") // Mark A as running and complete it entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded // 2. After A is done, both B and C should be ready (order doesn't matter) readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) assert.Len(t, readyEntries, 2, "After A is done, B and C should be ready") paths := []string{readyEntries[0].Component.Path(), readyEntries[1].Component.Path()} assert.Contains(t, paths, "B") assert.Contains(t, paths, "C") for _, entry := range readyEntries { assert.Equal(t, queue.StatusReady, entry.Status, "Entry %s should have StatusReady", entry.Component.Path()) } // Mark B as running and complete it var entryB, entryC *queue.Entry for _, entry := range readyEntries { if entry.Component.Path() == "B" { entryB = entry } if entry.Component.Path() == "C" { entryC = entry } } entryB.Status = queue.StatusSucceeded // After B is done, C should still be ready (if not already marked) readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) if entryC.Status != queue.StatusSucceeded { assert.Len(t, readyEntries, 1, "After B is done, C should still be ready") assert.Equal(t, "C", readyEntries[0].Component.Path()) entryC.Status = queue.StatusSucceeded } // After C is done, nothing should be ready readyEntries = q.GetReadyWithDependencies(logger.CreateLogger()) assert.Empty(t, readyEntries, "After B and C are done, no entries should be ready") } func TestQueue_FailFast(t *testing.T) { t.Parallel() // A // / \ // B C cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgA) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = true assert.False(t, q.Finished(), "Finished should be false at start") // Simulate A failing var entryA *queue.Entry for _, entry := range q.Entries { if entry.Component.Path() == "A" { entryA = entry break } } require.NotNil(t, entryA, "Entry A should exist") entryA.Status = queue.StatusRunning q.FailEntry(entryA) // B and C should be marked as early exit due to fail-fast for _, entry := range q.Entries { switch entry.Component.Path() { case "A": assert.Equal(t, queue.StatusFailed, entry.Status, "Entry %s should have StatusFailed", entry.Component.Path()) case "B", "C": assert.Equal(t, queue.StatusEarlyExit, entry.Status, "Entry %s should have StatusEarlyExit", entry.Component.Path()) } } // All entries should be listed as terminal (A: Failed, B/C: EarlyExit) for _, entry := range q.Entries { assert.True(t, entry.Status == queue.StatusFailed || entry.Status == queue.StatusEarlyExit, "Entry %s should be terminal", entry.Component.Path()) } // Now all should be terminal assert.True(t, q.Finished(), "Finished should be true after fail-fast triggers") // No entries should be ready after fail-fast readyEntries := q.GetReadyWithDependencies(logger.CreateLogger()) assert.Empty(t, readyEntries, "No entries should be ready after fail-fast triggers") } // buildMultiLevelDependencyTree returns the configs for the following dependency tree: // // A // / \ // B C // // / \ // D E func buildMultiLevelDependencyTree() component.Components { cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgA) cfgD := component.NewUnit("D") cfgD.AddDependency(cfgB) cfgE := component.NewUnit("E") cfgE.AddDependency(cfgB) components := component.Components{cfgA, cfgB, cfgC, cfgD, cfgE} return components } func TestQueue_AdvancedDependencyOrder(t *testing.T) { t.Parallel() l := logger.CreateLogger() configs := buildMultiLevelDependencyTree() q, err := queue.NewQueue(configs) require.NoError(t, err) // 1. Initially, only A should be ready readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path()) // Mark A as succeeded entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded // 2. After A, B and C should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 2, "After A, B and C should be ready") paths := []string{readyEntries[0].Component.Path(), readyEntries[1].Component.Path()} assert.Contains(t, paths, "B") assert.Contains(t, paths, "C") // Mark B as succeeded var entryB, entryC *queue.Entry for _, entry := range readyEntries { if entry.Component.Path() == "B" { entryB = entry } if entry.Component.Path() == "C" { entryC = entry } } entryB.Status = queue.StatusSucceeded // 3. After B is done, C should still be ready (if not already marked), and D and E should be ready readyEntries = q.GetReadyWithDependencies(l) readyPaths := map[string]bool{} for _, entry := range readyEntries { readyPaths[entry.Component.Path()] = true } // C may still be ready if not yet marked as succeeded assert.Contains(t, readyPaths, "C") assert.Contains(t, readyPaths, "D") assert.Contains(t, readyPaths, "E") assert.Len(t, readyEntries, 3, "After B is done, C, D, and E should be ready") // Mark C as succeeded entryC.Status = queue.StatusSucceeded // Mark D and E as succeeded var entryD, entryE *queue.Entry for _, entry := range readyEntries { if entry.Component.Path() == "D" { entryD = entry } if entry.Component.Path() == "E" { entryE = entry } } entryD.Status = queue.StatusSucceeded entryE.Status = queue.StatusSucceeded // 4. After all are done, nothing should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Empty(t, readyEntries, "After all are done, no entries should be ready") } func TestQueue_AdvancedDependency_BFails(t *testing.T) { t.Parallel() l := logger.CreateLogger() configs := buildMultiLevelDependencyTree() q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = true // 1. Initially, only A should be ready readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path()) // Mark A as succeeded entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded // 2. After A, B and C should be ready readyEntries = q.GetReadyWithDependencies(l) var entryB, entryC *queue.Entry for _, entry := range readyEntries { if entry.Component.Path() == "B" { entryB = entry } if entry.Component.Path() == "C" { entryC = entry } } assert.NotNil(t, entryB) assert.NotNil(t, entryC) // Mark B as failed entryB.Status = queue.StatusRunning q.FailEntry(entryB) // Fail fast should mark all not-yet-started tasks as early exit assert.Equal(t, queue.StatusFailed, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("D").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("E").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("C").Status) readyEntries = q.GetReadyWithDependencies(l) assert.Empty(t, readyEntries, "All entries should be terminal") } func TestQueue_AdvancedDependency_BFails_NoFailFast(t *testing.T) { t.Parallel() l := logger.CreateLogger() configs := buildMultiLevelDependencyTree() q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = false assert.False(t, q.Finished(), "Finished should be false at start") // 1. Initially, only A should be ready readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path()) // Mark A as succeeded entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded assert.False(t, q.Finished(), "Finished should be false after A is done") // 2. After A, B and C should be ready readyEntries = q.GetReadyWithDependencies(l) var entryB, entryC *queue.Entry for _, entry := range readyEntries { if entry.Component.Path() == "B" { entryB = entry } if entry.Component.Path() == "C" { entryC = entry } } assert.NotNil(t, entryB) assert.NotNil(t, entryC) // Mark B as failed entryB.Status = queue.StatusRunning q.FailEntry(entryB) assert.False(t, q.Finished(), "Finished should be false after B fails if C is not done") // D and E should be marked as early exit due to dependency on B assert.Equal(t, queue.StatusFailed, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("D").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("E").Status) // C should still be ready readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Only C should be ready after B fails") assert.Equal(t, "C", readyEntries[0].Component.Path()) // Mark C as succeeded entryC.Status = queue.StatusSucceeded // After C is done, now all should be terminal assert.True(t, q.Finished(), "Finished should be true after all entries are terminal") // After C is done, nothing should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Empty(t, readyEntries, "After C is done, no entries should be ready") } func TestQueue_FailFast_SequentialOrder(t *testing.T) { t.Parallel() l := logger.CreateLogger() // A -> B -> C, where A fails and fail-fast is enabled cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = true assert.False(t, q.Finished(), "Finished should be false at start") // Only A should be ready readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path()) // Mark A as running and then failed entryA := readyEntries[0] entryA.Status = queue.StatusRunning q.FailEntry(entryA) // After fail-fast, B and C should be early exit, A should be failed for _, entry := range q.Entries { switch entry.Component.Path() { case "A": assert.Equal(t, queue.StatusFailed, entry.Status, "Entry %s should have StatusFailed", entry.Component.Path()) case "B", "C": assert.Equal(t, queue.StatusEarlyExit, entry.Status, "Entry %s should have StatusEarlyExit", entry.Component.Path()) } } // Finished should be true assert.True(t, q.Finished(), "Finished should be true after fail-fast triggers") // No entries should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Empty(t, readyEntries, "No entries should be ready after fail-fast triggers") } func TestQueue_IgnoreDependencyOrder_MultiLevel(t *testing.T) { t.Parallel() l := logger.CreateLogger() configs := buildMultiLevelDependencyTree() q, err := queue.NewQueue(configs) require.NoError(t, err) q.IgnoreDependencyOrder = true readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 5, "Should be ready all entries") } func TestFailEntry_DirectAndRecursive(t *testing.T) { t.Parallel() // Build a graph: A -> B -> C, A -> D cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) cfgD := component.NewUnit("D") cfgD.AddDependency(cfgA) configs := component.Components{cfgA, cfgB, cfgC, cfgD} q, err := queue.NewQueue(configs) require.NoError(t, err) // Non-fail-fast: Should recursively mark all dependencies as StatusEarlyExit q.FailFast = false entryA := q.EntryByPath("A") q.FailEntry(entryA) assert.Equal(t, queue.StatusFailed, q.EntryByPath("A").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("C").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("D").Status) // Reset statuses for fail-fast test q, err = queue.NewQueue(configs) require.NoError(t, err) q.FailFast = true entryA = q.EntryByPath("A") q.FailEntry(entryA) assert.Equal(t, queue.StatusFailed, q.EntryByPath("A").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("C").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("D").Status) } func TestQueue_DestroyFail_PropagatesToDependencies_NonFailFast(t *testing.T) { t.Parallel() // Build a graph: A -> B -> C, A -> D cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) cfgD := component.NewUnit("D") cfgD.AddDependency(cfgA) configs := component.Components{cfgA, cfgB, cfgC, cfgD} // Set all configs to destroy (down) command for _, cfg := range configs { cfg.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) } q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = false // Fail C (should mark B and A as early exit, D should remain ready) entryC := q.EntryByPath("C") q.FailEntry(entryC) assert.Equal(t, queue.StatusFailed, q.EntryByPath("C").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("A").Status) assert.Equal(t, queue.StatusReady, q.EntryByPath("D").Status) } func TestQueue_DestroyFail_PropagatesToDependencies(t *testing.T) { t.Parallel() // Build a graph: A -> B -> C, A -> D cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) cfgD := component.NewUnit("D") cfgD.AddDependency(cfgA) configs := component.Components{cfgA, cfgB, cfgC, cfgD} // Set all configs to destroy (down) command for _, cfg := range configs { cfg.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) } // Only test fail-fast mode here q, err := queue.NewQueue(configs) require.NoError(t, err) q.FailFast = true entryC := q.EntryByPath("C") q.FailEntry(entryC) // All non-terminal entries should be early exit assert.Equal(t, queue.StatusFailed, q.EntryByPath("C").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("B").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("A").Status) assert.Equal(t, queue.StatusEarlyExit, q.EntryByPath("D").Status) } func TestDestroyCommandQueueOrderIsReverseOfDependencies(t *testing.T) { t.Parallel() // Create a simple chain: A -> B -> C cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) // Set all configs to destroy (down) command cfgA.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgB.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgC.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) entries := q.Entries // For destroy, the queue should be in reverse dependency order: C, B, A assert.Equal(t, "C", entries[0].Component.Path()) assert.Equal(t, "B", entries[1].Component.Path()) assert.Equal(t, "A", entries[2].Component.Path()) } func TestDestroyCommandQueueOrder_MultiLevelDependencyTree(t *testing.T) { t.Parallel() configs := buildMultiLevelDependencyTree() for _, cfg := range configs { cfg.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) } q, err := queue.NewQueue(configs) require.NoError(t, err) var processed []string for { ready := q.GetReadyWithDependencies(logger.CreateLogger()) if len(ready) == 0 { break } for _, entry := range ready { processed = append(processed, entry.Component.Path()) entry.Status = queue.StatusSucceeded } } // For destruction, the queue should be in reverse dependency order: E, D, C, B, A expected := []string{"C", "D", "E", "B", "A"} assert.Equal(t, expected, processed) } // TestQueue_DestroyWithIgnoreDependencyErrors_MaintainsOrder tests that when IgnoreDependencyErrors is true, // the queue still respects dependency order for destroy operations. This is the bug reported in issue #4947. // When a dependent fails, we should still wait for it to be in a terminal state before destroying the dependency. func TestQueue_DestroyWithIgnoreDependencyErrors_MaintainsOrder(t *testing.T) { t.Parallel() l := logger.CreateLogger() // Build a graph: A -> B -> C // For destroy, the order should be: C (destroyed first), then B, then A cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) // Set all configs to destroy (down) command cfgA.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgB.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgC.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) // Enable IgnoreDependencyErrors - this is the --queue-ignore-errors flag q.IgnoreDependencyErrors = true // Step 1: Only C should be ready (it has no dependents) readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only C should be ready for destruction") assert.Equal(t, "C", readyEntries[0].Component.Path(), "C should be the first entry ready for destruction") // Mark C as succeeded entryC := readyEntries[0] entryC.Status = queue.StatusSucceeded // Step 2: After C is destroyed, B should be ready (but NOT A yet, as A is a dependency of B) readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "After C is destroyed, only B should be ready") assert.Equal(t, "B", readyEntries[0].Component.Path(), "B should be ready after C is destroyed") // Mark B as succeeded entryB := readyEntries[0] entryB.Status = queue.StatusSucceeded // Step 3: After B is destroyed, A should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "After B is destroyed, only A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path(), "A should be ready last") // Mark A as succeeded entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded // Step 4: All entries should be finished readyEntries = q.GetReadyWithDependencies(l) assert.Empty(t, readyEntries, "After all are destroyed, no entries should be ready") assert.True(t, q.Finished(), "Queue should be finished") } // TestQueue_DestroyWithIgnoreDependencyErrors_AllowsProgressAfterFailure tests that when IgnoreDependencyErrors is true // and a dependent fails, we can still destroy the dependency once the dependent is in a terminal state. func TestQueue_DestroyWithIgnoreDependencyErrors_AllowsProgressAfterFailure(t *testing.T) { t.Parallel() l := logger.CreateLogger() // Build a graph: A -> B -> C cfgA := component.NewUnit("A") cfgB := component.NewUnit("B") cfgB.AddDependency(cfgA) cfgC := component.NewUnit("C") cfgC.AddDependency(cfgB) // Set all configs to destroy (down) command cfgA.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgB.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) cfgC.SetDiscoveryContext(&component.DiscoveryContext{Cmd: "destroy"}) configs := component.Components{cfgA, cfgB, cfgC} q, err := queue.NewQueue(configs) require.NoError(t, err) q.IgnoreDependencyErrors = true // Step 1: Only C should be ready readyEntries := q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "Initially only C should be ready") assert.Equal(t, "C", readyEntries[0].Component.Path()) // Mark C as FAILED (simulating a destroy failure) entryC := readyEntries[0] entryC.Status = queue.StatusRunning q.FailEntry(entryC) // With IgnoreDependencyErrors = true, B should NOT be marked as early exit // Instead, B should still be ready to run assert.Equal(t, queue.StatusFailed, q.EntryByPath("C").Status, "C should be failed") assert.Equal(t, queue.StatusReady, q.EntryByPath("B").Status, "B should still be ready (not early exit)") // Step 2: B should now be ready even though C failed readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "After C fails, B should still be ready due to IgnoreDependencyErrors") assert.Equal(t, "B", readyEntries[0].Component.Path()) // Mark B as succeeded entryB := readyEntries[0] entryB.Status = queue.StatusSucceeded // Step 3: After B succeeds, A should be ready readyEntries = q.GetReadyWithDependencies(l) assert.Len(t, readyEntries, 1, "After B succeeds, A should be ready") assert.Equal(t, "A", readyEntries[0].Component.Path()) // Mark A as succeeded entryA := readyEntries[0] entryA.Status = queue.StatusSucceeded // Queue should be finished assert.True(t, q.Finished(), "Queue should be finished") } func TestSetEntryStatus_TerminalGuard(t *testing.T) { t.Parallel() testCases := []struct { initial queue.Status attempted queue.Status }{ {queue.StatusSucceeded, queue.StatusFailed}, {queue.StatusSucceeded, queue.StatusEarlyExit}, {queue.StatusSucceeded, queue.StatusRunning}, {queue.StatusFailed, queue.StatusSucceeded}, {queue.StatusFailed, queue.StatusEarlyExit}, {queue.StatusEarlyExit, queue.StatusSucceeded}, {queue.StatusEarlyExit, queue.StatusFailed}, } for _, tc := range testCases { t.Run(fmt.Sprintf("%v_to_%v", tc.initial, tc.attempted), func(t *testing.T) { t.Parallel() configs := component.Components{component.NewUnit("Test")} q, err := queue.NewQueue(configs) require.NoError(t, err) entry := q.EntryByPath("Test") // Set initial terminal state directly entry.Status = tc.initial // Attempt transition via SetEntryStatus q.SetEntryStatus(entry, tc.attempted) assert.Equal(t, tc.initial, entry.Status, "Terminal status should not change") }) } } ================================================ FILE: internal/remotestate/backend/backend.go ================================================ // Package backend represents a backend for interacting with remote state. package backend import ( "context" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Options contains the subset of configuration needed by backend operations. type Options struct { Writers writer.Writers Env map[string]string IAMRoleOptions iam.RoleOptions NonInteractive bool FailIfBucketCreationRequired bool } type Backends []Backend // Get returns the backend by the given name. func (backends Backends) Get(name string) Backend { for _, backend := range backends { if backend.Name() == name { return backend } } return nil } type Backend interface { // Names returns the backend name. Name() string // IsVersionControlEnabled returns true if the version control is enabled. IsVersionControlEnabled(ctx context.Context, l log.Logger, config Config, opts *Options) (bool, error) // NeedsBootstrap returns true if remote state needs to be bootstrapped. NeedsBootstrap(ctx context.Context, l log.Logger, config Config, opts *Options) (bool, error) // Bootstrap bootstraps the remote state. Bootstrap(ctx context.Context, l log.Logger, config Config, opts *Options) error // Migrate determines where the remote state resources exist for source backend config and migrate them to dest backend config. Migrate(ctx context.Context, l log.Logger, srcConfig, dstConfig Config, opts *Options) error // Delete deletes the remote state. Delete(ctx context.Context, l log.Logger, config Config, opts *Options) error // DeleteBucket deletes the entire bucket. DeleteBucket(ctx context.Context, l log.Logger, config Config, opts *Options) error // GetTFInitArgs returns the config that should be passed on to `tofu -backend-config` cmd line param // Allows the Backends to filter and/or modify the configuration given from the user. GetTFInitArgs(config Config) map[string]any } ================================================ FILE: internal/remotestate/backend/common.go ================================================ package backend import ( "context" "sync" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/puzpuzpuz/xsync/v3" ) var _ Backend = new(CommonBackend) type CommonBackend struct { bucketLocks *xsync.MapOf[string, *sync.Mutex] initedConfigs *xsync.MapOf[string, bool] name string } func NewCommonBackend(name string) *CommonBackend { return &CommonBackend{ name: name, bucketLocks: xsync.NewMapOf[string, *sync.Mutex](), initedConfigs: xsync.NewMapOf[string, bool](), } } // Name implements `backends.Backend` interface. func (backend *CommonBackend) Name() string { return backend.name } func (backend *CommonBackend) IsVersionControlEnabled(ctx context.Context, l log.Logger, config Config, opts *Options) (bool, error) { l.Warnf("Checking version control for %s backend not implemented.", backend.Name()) return false, nil } // NeedsBootstrap implements `backends.NeedsBootstrap` interface. func (backend *CommonBackend) NeedsBootstrap(ctx context.Context, l log.Logger, config Config, opts *Options) (bool, error) { return false, nil } // Bootstrap implements `backends.Bootstrap` interface. func (backend *CommonBackend) Bootstrap(ctx context.Context, l log.Logger, config Config, opts *Options) error { l.Warnf("Bootstrap for %s backend not implemented.", backend.Name()) return nil } // Migrate implements `backends.Migrate` interface. func (backend *CommonBackend) Migrate(ctx context.Context, l log.Logger, srcConfig, dstConfig Config, opts *Options) error { l.Warnf("Migrate for %s backend not implemented.", backend.Name()) return nil } // Delete implements `backends.Delete` interface. func (backend *CommonBackend) Delete(ctx context.Context, l log.Logger, config Config, opts *Options) error { l.Warnf("Delete for %s backend not implemented.", backend.Name()) return nil } // DeleteBucket implements `backends.DeleteBucket` interface. func (backend *CommonBackend) DeleteBucket(ctx context.Context, l log.Logger, config Config, opts *Options) error { l.Warnf("Deleting entire bucket for %s backend not implemented.", backend.Name()) return nil } // GetTFInitArgs implements `backends.GetTFInitArgs` interface. func (backend *CommonBackend) GetTFInitArgs(config Config) map[string]any { return config } func (backend *CommonBackend) GetBucketMutex(bucketName string) *sync.Mutex { mu, _ := backend.bucketLocks.LoadOrCompute(bucketName, func() *sync.Mutex { return new(sync.Mutex) }) return mu } func (backend *CommonBackend) IsConfigInited(config interface{ CacheKey() string }) bool { status, ok := backend.initedConfigs.Load(config.CacheKey()) return ok && status } func (backend *CommonBackend) MarkConfigInited(config interface{ CacheKey() string }) { backend.initedConfigs.Store(config.CacheKey(), true) } ================================================ FILE: internal/remotestate/backend/config.go ================================================ package backend import ( "reflect" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( configPathKey = "path" ) type Config map[string]any // Path returns the `patha field value. func (cfg Config) Path() string { return getConfigValueByKey[string](cfg, configPathKey) } // IsEqual returns true if the given `targetCfg` config is in any way different than what is configured for the backend. func (cfg Config) IsEqual(targetCfg Config, backendName string, logger log.Logger) bool { if len(cfg) == 0 && len(targetCfg) == 0 { return true } targetCfgNonNil := cfg.CopyNotNullValues(targetCfg) if reflect.DeepEqual(targetCfgNonNil, map[string]any(cfg)) { logger.Debugf("Backend %s has not changed.", backendName) return true } logger.Debugf("Backend config %s has changed from %s to %s", backendName, targetCfgNonNil, cfg) return false } // CopyNotNullValues copies the non-nil values from the `targetCfg` whose keys also exist in the `cfg` to the new map. func (cfg Config) CopyNotNullValues(targetCfg map[string]any) map[string]any { if targetCfg == nil { return nil } targetCfgNonNil := map[string]any{} for existingKey, existingValue := range targetCfg { newValue, newValueIsSet := cfg[existingKey] if existingValue == nil && !newValueIsSet { continue } // if newValue and existingValue are both maps, we need to recursively copy the non-nil values if existingValueMap, existingValueIsMap := existingValue.(map[string]any); existingValueIsMap { if newValueMap, newValueIsMap := newValue.(map[string]any); newValueIsMap { existingValue = Config(newValueMap).CopyNotNullValues(existingValueMap) } } targetCfgNonNil[existingKey] = existingValue } return targetCfgNonNil } func getConfigValueByKey[T any](m map[string]any, key string) T { if val, ok := m[key]; ok { if val, ok := val.(T); ok { return val } } return *new(T) } ================================================ FILE: internal/remotestate/backend/config_test.go ================================================ package backend_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" ) func TestConfig_IsEqual(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string existingBackend backend.Config cfg backend.Config expected bool }{ { "both empty", backend.Config{}, backend.Config{}, true, }, { "identical S3 configs", backend.Config{"bucket": "foo", "key": "bar", "region": "us-east-1"}, backend.Config{"bucket": "foo", "key": "bar", "region": "us-east-1"}, true, }, { "identical GCS configs", backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "bar"}, backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "bar"}, true, }, { "different s3 bucket values", backend.Config{"bucket": "foo", "key": "bar", "region": "us-east-1"}, backend.Config{"bucket": "different", "key": "bar", "region": "us-east-1"}, false, }, { "different gcs bucket values", backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "bar"}, backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "different", "prefix": "bar"}, false, }, { "different s3 key values", backend.Config{"bucket": "foo", "key": "bar", "region": "us-east-1"}, backend.Config{"bucket": "foo", "key": "different", "region": "us-east-1"}, false, }, { "different gcs prefix values", backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "bar"}, backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "different"}, false, }, { "different s3 region values", backend.Config{"bucket": "foo", "key": "bar", "region": "us-east-1"}, backend.Config{"bucket": "foo", "key": "bar", "region": "different"}, false, }, { "different gcs location values", backend.Config{"project": "foo-123456", "location": "europe-west3", "bucket": "foo", "prefix": "bar"}, backend.Config{"project": "foo-123456", "location": "different", "bucket": "foo", "prefix": "bar"}, false, }, { "different boolean values and boolean conversion", backend.Config{"something": "true"}, backend.Config{"something": false}, false, }, { "different gcs boolean values and boolean conversion", backend.Config{"something": "true"}, backend.Config{"something": false}, false, }, { "null values ignored", backend.Config{"something": "foo", "set-to-nil-should-be-ignored": nil}, backend.Config{"something": "foo"}, true, }, { "gcs null values ignored", backend.Config{"something": "foo", "set-to-nil-should-be-ignored": nil}, backend.Config{"something": "foo"}, true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := tc.cfg.IsEqual(tc.existingBackend, "", log.Default()) assert.Equal(t, tc.expected, actual, "Expect differsFrom to return %t but got %t for existingRemoteState %v and remoteStateFromTerragruntConfig %v", tc.expected, actual, tc.existingBackend, tc.cfg) }) } } ================================================ FILE: internal/remotestate/backend/errors.go ================================================ package backend import ( "fmt" ) type BucketCreationNotAllowed string func (bucketName BucketCreationNotAllowed) Error() string { return fmt.Sprintf("Creation of remote state bucket %s is not allowed", string(bucketName)) } // BucketDoesNotExistError is the error that is returned when the bucket does not exist. type BucketDoesNotExistError struct { bucketName string } // NewBucketDoesNotExistError creates a new `BucketDoesNotExistError` instance. func NewBucketDoesNotExistError(bucketName string) *BucketDoesNotExistError { return &BucketDoesNotExistError{bucketName: bucketName} } // Error implements `error` interface. func (err BucketDoesNotExistError) Error() string { return fmt.Sprintf("S3 bucket %s does not exist", err.bucketName) } ================================================ FILE: internal/remotestate/backend/gcs/backend.go ================================================ // Package gcs represents GCS backend for interacting with remote state. package gcs import ( "context" "fmt" "path" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( BackendName = "gcs" defaultTfState = "default.tfstate" ) var _ backend.Backend = new(Backend) type Backend struct { *backend.CommonBackend } func NewBackend() *Backend { return &Backend{ CommonBackend: backend.NewCommonBackend(BackendName), } } // NeedsBootstrap returns true if the GCS bucket specified in the given config does not exist or if the bucket // exists but versioning is not enabled. // // Returns true if: // // 1. Any of the existing backend settings are different than the current config // 2. The configured GCS bucket does not exist func (backend *Backend) NeedsBootstrap(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) (bool, error) { extGCSCfg, err := Config(backendConfig).ExtendedGCSConfig() if err != nil { return false, err } var ( gcsCfg = &extGCSCfg.RemoteStateConfigGCS bucketName = gcsCfg.Bucket ) client, err := NewClient(ctx, extGCSCfg, opts) if err != nil { return false, err } defer func() { if err := client.Close(); err != nil { l.Warnf("Error closing GCS client: %v", err) } }() if !client.DoesGCSBucketExist(ctx, bucketName) { return true, nil } return false, nil } // Bootstrap the remote state GCS bucket specified in the given config. This function will validate the config // parameters, create the GCS bucket if it doesn't already exist, and check that versioning is enabled. func (backend *Backend) Bootstrap(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) error { extGCSCfg, err := Config(backendConfig).ExtendedGCSConfig() if err != nil { return err } client, err := NewClient(ctx, extGCSCfg, opts) if err != nil { return err } defer func() { if err := client.Close(); err != nil { l.Warnf("Error closing GCS client: %v", err) } }() var ( gcsCfg = &extGCSCfg.RemoteStateConfigGCS bucketName = gcsCfg.Bucket ) // ensure that only one goroutine can initialize bucket mu := backend.GetBucketMutex(bucketName) mu.Lock() defer mu.Unlock() if backend.IsConfigInited(gcsCfg) { l.Debugf("%s bucket %s has already been confirmed to be initialized, skipping initialization checks", backend.Name(), bucketName) return nil } // If bucket is specified and skip_bucket_creation is false then check if Bucket needs to be created if !extGCSCfg.SkipBucketCreation && bucketName != "" { if err := client.CreateGCSBucketIfNecessary(ctx, l, bucketName, opts); err != nil { return err } } // If bucket is specified and skip_bucket_versioning is false then warn user if versioning is disabled on bucket if !extGCSCfg.SkipBucketVersioning && bucketName != "" { if _, err := client.CheckIfGCSVersioningEnabled(ctx, l, bucketName); err != nil { return err } } backend.MarkConfigInited(gcsCfg) return nil } // IsVersionControlEnabled returns true if version control for gcs bucket is enabled. func (backend *Backend) IsVersionControlEnabled(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) (bool, error) { extGCSCfg, err := Config(backendConfig).ExtendedGCSConfig() if err != nil { return false, err } var bucketName = extGCSCfg.RemoteStateConfigGCS.Bucket client, err := NewClient(ctx, extGCSCfg, opts) if err != nil { return false, err } return client.CheckIfGCSVersioningEnabled(ctx, l, bucketName) } func (backend *Backend) Migrate(ctx context.Context, l log.Logger, srcBackendConfig, dstBackendConfig backend.Config, opts *backend.Options) error { srcExtGCSCfg, err := Config(srcBackendConfig).ExtendedGCSConfig() if err != nil { return err } dstExtGCSCfg, err := Config(dstBackendConfig).ExtendedGCSConfig() if err != nil { return err } var ( srcBucketName = srcExtGCSCfg.RemoteStateConfigGCS.Bucket srcBucketKey = path.Join(srcExtGCSCfg.RemoteStateConfigGCS.Prefix, defaultTfState) dstBucketName = dstExtGCSCfg.RemoteStateConfigGCS.Bucket dstBucketKey = path.Join(dstExtGCSCfg.RemoteStateConfigGCS.Prefix, defaultTfState) ) client, err := NewClient(ctx, srcExtGCSCfg, opts) if err != nil { return err } return client.MoveGCSObjectIfNecessary(ctx, l, srcBucketName, srcBucketKey, dstBucketName, dstBucketKey) } // Delete deletes the remote state specified in the given config. func (backend *Backend) Delete(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) error { extGCSCfg, err := Config(backendConfig).ExtendedGCSConfig() if err != nil { return err } var ( bucketName = extGCSCfg.RemoteStateConfigGCS.Bucket prefix = extGCSCfg.RemoteStateConfigGCS.Prefix ) client, err := NewClient(ctx, extGCSCfg, opts) if err != nil { return err } prompt := fmt.Sprintf("GCS bucket %s objects with prefix %s will be deleted. Do you want to continue?", bucketName, prefix) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { return client.DeleteGCSObjectIfNecessary(ctx, l, bucketName, prefix) } return nil } // DeleteBucket deletes the entire bucket specified in the given config. func (backend *Backend) DeleteBucket(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) error { extGCSCfg, err := Config(backendConfig).ExtendedGCSConfig() if err != nil { return err } client, err := NewClient(ctx, extGCSCfg, opts) if err != nil { return err } var bucketName = extGCSCfg.RemoteStateConfigGCS.Bucket prompt := fmt.Sprintf("GCS bucket %s will be completely deleted. Do you want to continue?", bucketName) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { return client.DeleteGCSBucketIfNecessary(ctx, l, bucketName) } return nil } // GetTFInitArgs returns the subset of the given config that should be passed to terraform init // when initializing the remote state. func (backend *Backend) GetTFInitArgs(config backend.Config) map[string]any { return Config(config).GetTFInitArgs() } ================================================ FILE: internal/remotestate/backend/gcs/backend_test.go ================================================ package gcs_test import ( "testing" backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" gcsbackend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/gcs" "github.com/stretchr/testify/assert" ) func TestBackend_GetTFInitArgs(t *testing.T) { t.Parallel() remoteBackend := gcsbackend.NewBackend() testCases := []struct { config backend.Config expected map[string]any name string }{ { name: "empty-no-values", config: backend.Config{}, expected: map[string]any{}, }, { name: "valid-gcs-configuration-keys", config: backend.Config{ "bucket": "my-bucket", "prefix": "terraform/state", "credentials": "path/to/creds.json", }, expected: map[string]any{ "bucket": "my-bucket", "prefix": "terraform/state", "credentials": "path/to/creds.json", }, }, { name: "terragrunt-keys-filtered", config: backend.Config{ "bucket": "my-bucket", "prefix": "terraform/state", "project": "my-project", "location": "us-central1", "gcs_bucket_labels": map[string]string{"env": "prod"}, "skip_bucket_versioning": true, "skip_bucket_creation": true, "enable_bucket_policy_only": true, }, expected: map[string]any{ "bucket": "my-bucket", "prefix": "terraform/state", }, }, { name: "empty-after-all-terragrunt-keys-filtered", config: backend.Config{ "project": "my-project", "location": "us-central1", "gcs_bucket_labels": map[string]string{}, "skip_bucket_versioning": true, "skip_bucket_creation": false, "enable_bucket_policy_only": false, }, expected: map[string]any{}, }, { name: "string-bool-normalization-passthrough", config: backend.Config{ "bucket": "my-bucket", "prefix": "terraform/state", }, expected: map[string]any{ "bucket": "my-bucket", "prefix": "terraform/state", }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := remoteBackend.GetTFInitArgs(tc.config) assert.Equal(t, tc.expected, actual) }) } } ================================================ FILE: internal/remotestate/backend/gcs/client.go ================================================ package gcs import ( "context" "fmt" "path" "time" "cloud.google.com/go/storage" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/gcphelper" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "google.golang.org/api/iterator" ) const ( maxRetriesWaitingForGcsBucket = 12 sleepBetweenRetriesWaitingForGcsBucket = 5 * time.Second gcpMaxRetries = 3 gcpSleepBetweenRetries = 10 * time.Second ) type Client struct { *ExtendedRemoteStateConfigGCS *storage.Client } // NewClient inits GCS client. func NewClient( ctx context.Context, config *ExtendedRemoteStateConfigGCS, opts *backend.Options, ) (*Client, error) { gcsClient, err := gcphelper.NewGCPConfigBuilder(). WithSessionConfig(config.GetGCPSessionConfig()). WithEnv(opts.Env). BuildGCSClient(ctx) if err != nil { return nil, err } client := &Client{ ExtendedRemoteStateConfigGCS: config, Client: gcsClient, } return client, nil } // CreateGCSBucketIfNecessary prompts the user to create the given bucket if it doesn't already exist and if the user // confirms, creates the bucket and enables versioning for it. func (client *Client) CreateGCSBucketIfNecessary(ctx context.Context, l log.Logger, bucketName string, opts *backend.Options) error { if client.DoesGCSBucketExist(ctx, bucketName) { return nil } // A project must be specified in order for terragrunt to automatically create a storage bucket. if client.Project == "" { return errors.New(MissingRequiredGCSRemoteStateConfig("project")) } // A location must be specified in order for terragrunt to automatically create a storage bucket. if client.Location == "" { return errors.New(MissingRequiredGCSRemoteStateConfig("location")) } l.Debugf("Remote state GCS bucket %s does not exist. Attempting to create it", bucketName) if opts.FailIfBucketCreationRequired { return backend.BucketCreationNotAllowed(bucketName) } prompt := fmt.Sprintf("Remote state GCS bucket %s does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", bucketName) shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { return err } if shouldCreateBucket { // To avoid any eventual consistency issues with creating a GCS bucket we use a retry loop. description := "Create GCS bucket " + bucketName return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { return client.CreateGCSBucketWithVersioning(ctx, l, bucketName) }) } return nil } // CheckIfGCSVersioningEnabled checks if versioning is enabled for the GCS bucket specified in the given config and warn the user if it is not func (client *Client) CheckIfGCSVersioningEnabled(ctx context.Context, l log.Logger, bucketName string) (bool, error) { bucket := client.Bucket(bucketName) if !client.DoesGCSBucketExist(ctx, bucketName) { return false, backend.NewBucketDoesNotExistError(bucketName) } attrs, err := bucket.Attrs(ctx) if err != nil { // ErrBucketNotExist return false, errors.New(err) } if !attrs.VersioningEnabled { l.Warnf("Versioning is not enabled for the remote state GCS bucket %s. We recommend enabling versioning so that you can roll back to previous versions of your OpenTofu/Terraform state in case of error.", bucketName) } return attrs.VersioningEnabled, nil } // CreateGCSBucketWithVersioning creates the given GCS bucket and enables versioning for it. func (client *Client) CreateGCSBucketWithVersioning(ctx context.Context, l log.Logger, bucketName string) error { if err := client.CreateGCSBucket(ctx, l, bucketName); err != nil { return err } if err := client.WaitUntilGCSBucketExists(ctx, l, bucketName); err != nil { return err } if err := client.AddLabelsToGCSBucket(ctx, l, bucketName, client.GCSBucketLabels); err != nil { return err } return nil } // AddLabelsToGCSBucket adds the given labels to the GCS bucket. func (client *Client) AddLabelsToGCSBucket(ctx context.Context, l log.Logger, bucketName string, labels map[string]string) error { if len(labels) == 0 { l.Debugf("No labels specified for bucket %s.", bucketName) return nil } l.Debugf("Adding labels to GCS bucket with %s", labels) bucket := client.Bucket(bucketName) bucketAttrs := storage.BucketAttrsToUpdate{} for key, value := range labels { bucketAttrs.SetLabel(key, value) } _, err := bucket.Update(ctx, bucketAttrs) if err != nil { return errors.New(err) } return nil } // CreateGCSBucket creates the GCS bucket specified in the given config. func (client *Client) CreateGCSBucket(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Creating GCS bucket %s in project %s", bucketName, client.Project) // The project ID to which the bucket belongs. This is only used when creating a new bucket during initialization. // Since buckets have globally unique names, the project ID is not required to access the bucket during normal // operation. projectID := client.Project bucket := client.Bucket(bucketName) bucketAttrs := &storage.BucketAttrs{} if client.Location != "" { l.Debugf("Creating GCS bucket in location %s.", client.Location) bucketAttrs.Location = client.Location } if client.SkipBucketVersioning { l.Debugf("Versioning is disabled for the remote state GCS bucket %s using 'skip_bucket_versioning' config.", bucketName) } else { l.Debugf("Enabling versioning on GCS bucket %s", bucketName) bucketAttrs.VersioningEnabled = true } if client.EnableBucketPolicyOnly { l.Debugf("Enabling uniform bucket-level access on GCS bucket %s", bucketName) bucketAttrs.BucketPolicyOnly = storage.BucketPolicyOnly{Enabled: true} } if err := bucket.Create(ctx, projectID, bucketAttrs); err != nil { return errors.Errorf("error creating GCS bucket %s: %w", bucketName, err) } return nil } // WaitUntilGCSBucketExists waits for the GCS bucket specified in the given config to be created. // // GCP is eventually consistent, so after creating a GCS bucket, this method can be used to wait until the information // about that GCS bucket has propagated everywhere. func (client *Client) WaitUntilGCSBucketExists(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Waiting for bucket %s to be created", bucketName) for retries := range maxRetriesWaitingForGcsBucket { if client.DoesGCSBucketExist(ctx, bucketName) { l.Debugf("GCS bucket %s created.", bucketName) return nil } if retries < maxRetriesWaitingForGcsBucket-1 { l.Debugf("GCS bucket %s has not been created yet. Sleeping for %s and will check again.", bucketName, sleepBetweenRetriesWaitingForGcsBucket) time.Sleep(sleepBetweenRetriesWaitingForGcsBucket) } } return errors.New(MaxRetriesWaitingForGCSBucketExceeded(bucketName)) } // DoesGCSBucketExist returns true if the GCS bucket specified in the given config exists and the current user has the // ability to access it. func (client *Client) DoesGCSBucketExist(ctx context.Context, bucketName string) bool { bucketHandle := client.Bucket(bucketName) // TODO - the code below attempts to determine whether the storage bucket exists by making a making a number of API // calls, then attempting to list the contents of the bucket. It was adapted from Google's own integration tests and // should be improved once the appropriate API call is added. For more info see: // https://github.com/GoogleCloudPlatform/google-cloud-go/blob/de879f7be552d57556875b8aaa383bce9396cc8c/storage/integration_test.go#L1231 if _, err := bucketHandle.Attrs(ctx); err != nil { // ErrBucketNotExist return false } it := bucketHandle.Objects(ctx, nil) if _, err := it.Next(); errors.Is(err, storage.ErrBucketNotExist) { return false } return true } // DeleteGCSBucketIfNecessary deletes the given GCS bucket with all its objects if it exists. func (client *Client) DeleteGCSBucketIfNecessary(ctx context.Context, l log.Logger, bucketName string) error { if !client.DoesGCSBucketExist(ctx, bucketName) { return nil } description := fmt.Sprintf("Delete GCS bucket %s with retry", bucketName) return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { if err := client.DeleteGCSObjects(ctx, l, bucketName, "", true); err != nil { return err } return client.DeleteGCSBucket(ctx, l, bucketName) }) } func (client *Client) DeleteGCSBucket(ctx context.Context, l log.Logger, bucketName string) error { bucket := client.Bucket(bucketName) l.Debugf("Deleting GCS bucket %s", bucketName) if err := bucket.Delete(ctx); err != nil { return errors.Errorf("error deleting GCS bucket %s: %w", bucketName, err) } l.Debugf("Deleted GCS bucket %s", bucketName) return client.WaitUntilGCSBucketDeleted(ctx, l, bucketName) } // WaitUntilGCSBucketDeleted waits for the GCS bucket specified in the given config to be deleted. func (client *Client) WaitUntilGCSBucketDeleted(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Waiting for bucket %s to be deleted", bucketName) for retries := range maxRetriesWaitingForGcsBucket { if !client.DoesGCSBucketExist(ctx, bucketName) { l.Debugf("GCS bucket %s deleted.", bucketName) return nil } else if retries < maxRetriesWaitingForGcsBucket-1 { l.Debugf("GCS bucket %s has not been deleted yet. Sleeping for %s and will check again.", bucketName, sleepBetweenRetriesWaitingForGcsBucket) time.Sleep(sleepBetweenRetriesWaitingForGcsBucket) } } return errors.New(MaxRetriesWaitingForGCSBucketExceeded(bucketName)) } // DeleteGCSObjectIfNecessary deletes the bucket objects with the given prefix if they exist. func (client *Client) DeleteGCSObjectIfNecessary(ctx context.Context, l log.Logger, bucketName, prefix string) error { if !client.DoesGCSBucketExist(ctx, bucketName) { return nil } description := fmt.Sprintf("Delete GCS objects with prefix %s in bucket %s with retry", prefix, bucketName) return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { return client.DeleteGCSObjects(ctx, l, bucketName, prefix, false) }) } // DeleteGCSObjects deletes the bucket objects with the given prefix. func (client *Client) DeleteGCSObjects(ctx context.Context, l log.Logger, bucketName, prefix string, withVersions bool) error { bucket := client.Bucket(bucketName) it := bucket.Objects(ctx, &storage.Query{ Prefix: prefix, Versions: withVersions, }) for { attrs, err := it.Next() if err != nil { if errors.Is(err, iterator.Done) { break } return errors.Errorf("failed to get GCS object attrs: %w", err) } l.Debugf("Deleting GCS object %s with generation %d in bucket %s", attrs.Name, attrs.Generation, bucketName) if err := bucket.Object(attrs.Name).Generation(attrs.Generation).Delete(ctx); err != nil { return errors.Errorf("failed to delete object %s with generation %d in bucket %s: %w", attrs.Name, attrs.Generation, bucketName, err) } } return nil } // MoveGCSObjectIfNecessary moves the GCS object at the specified srcBucketName and srcKey to dstBucketName and dstKey. func (client *Client) MoveGCSObjectIfNecessary(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { if exists, err := client.DoesGCSObjectExistWithLogging(ctx, l, srcBucketName, srcKey); err != nil || !exists { return err } if exists, err := client.DoesGCSObjectExist(ctx, dstBucketName, dstKey); err != nil { return err } else if exists { return errors.Errorf("destination GCS bucket %s object %s already exists", dstBucketName, dstKey) } description := fmt.Sprintf("Move GCS bucket object from %s to %s", path.Join(srcBucketName, srcKey), path.Join(dstBucketName, dstKey)) return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { return client.MoveGCSObject(ctx, l, srcBucketName, srcKey, dstBucketName, dstKey) }) } // DoesGCSObjectExist returns true if the specified GCS object exists otherwise false. func (client *Client) DoesGCSObjectExist(ctx context.Context, bucketName, key string) (bool, error) { bucket := client.Bucket(bucketName) obj := bucket.Object(key) if _, err := obj.Attrs(ctx); err != nil { if errors.Is(err, storage.ErrObjectNotExist) { return false, nil } return false, err } return true, nil } func (client *Client) DoesGCSObjectExistWithLogging(ctx context.Context, l log.Logger, bucketName, key string) (bool, error) { if exists, err := client.DoesGCSObjectExist(ctx, bucketName, key); err != nil || exists { return exists, err } l.Debugf("Remote state GCS bucket %s object %s does not exist or you don't have permissions to access it.", bucketName, key) return false, nil } // MoveGCSObject copies the GCS object at the specified srcKey to dstKey and then removes srcKey. func (client *Client) MoveGCSObject(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { if err := client.CopyGCSBucketObject(ctx, l, srcBucketName, srcKey, dstBucketName, dstKey); err != nil { return err } return client.DeleteGCSObjects(ctx, l, srcBucketName, srcKey, false) } // CopyGCSBucketObject copies the GCS object at the specified srcKey to dstKey. func (client *Client) CopyGCSBucketObject(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { l.Debugf("Copying GCS bucket object from %s to %s", path.Join(srcBucketName, srcKey), path.Join(dstBucketName, dstKey)) src := client.Bucket(srcBucketName).Object(srcKey) dst := client.Bucket(dstBucketName).Object(dstKey) if _, err := dst.CopierFrom(src).Run(ctx); err != nil { return errors.Errorf("failed to copy object: %w", err) } return nil } ================================================ FILE: internal/remotestate/backend/gcs/config.go ================================================ package gcs import ( "maps" "reflect" "slices" "strconv" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mitchellh/mapstructure" ) type Config map[string]any // GetTFInitArgs returns the config filtered and normalized for terraform init. func (cfg Config) GetTFInitArgs() Config { filtered := cfg.FilterOutTerragruntKeys() return Config(backend.NormalizeBoolValues(backend.Config(filtered), &ExtendedRemoteStateConfigGCS{})) } func (cfg Config) FilterOutTerragruntKeys() Config { var filtered = make(Config) for key, val := range cfg { if slices.Contains(terragruntOnlyConfigs, key) { continue } filtered[key] = val } return filtered } func (cfg Config) IsEqual(targetCfg Config, logger log.Logger) bool { // If other keys in config are bools, DeepEqual also will consider the maps to be different. for key, value := range targetCfg { if util.KindOf(targetCfg[key]) == reflect.String && util.KindOf(cfg[key]) == reflect.Bool { if convertedValue, err := strconv.ParseBool(value.(string)); err == nil { targetCfg[key] = convertedValue } } } // Construct a new map excluding custom GCS labels that are only used in Terragrunt config and not in Terraform's backend newConfig := backend.Config{} maps.Copy(newConfig, cfg.FilterOutTerragruntKeys()) return newConfig.IsEqual(backend.Config(targetCfg), BackendName, logger) } // ParseExtendedGCSConfig parses the given map into a GCS config. func (cfg Config) ParseExtendedGCSConfig() (*ExtendedRemoteStateConfigGCS, error) { var ( gcsConfig RemoteStateConfigGCS extendedConfig ExtendedRemoteStateConfigGCS ) if err := mapstructure.WeakDecode(cfg, &gcsConfig); err != nil { return nil, errors.New(err) } if err := mapstructure.WeakDecode(cfg, &extendedConfig); err != nil { return nil, errors.New(err) } extendedConfig.RemoteStateConfigGCS = gcsConfig return &extendedConfig, nil } // ExtendedGCSConfig parses the given map into an extended GCS config and validates this config. func (cfg Config) ExtendedGCSConfig() (*ExtendedRemoteStateConfigGCS, error) { extGCSCfg, err := cfg.ParseExtendedGCSConfig() if err != nil { return nil, err } return extGCSCfg, extGCSCfg.Validate() } ================================================ FILE: internal/remotestate/backend/gcs/config_test.go ================================================ package gcs_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/gcs" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfig_IsEqual(t *testing.T) { t.Parallel() logger := logger.CreateLogger() testCases := []struct { //nolint: govet name string cfg gcs.Config comparableCfg gcs.Config shouldBeEqual bool }{ { "equal-both-empty", gcs.Config{}, gcs.Config{}, true, }, { "equal-empty-and-nil", gcs.Config{}, nil, true, }, { "equal-one-key", gcs.Config{"foo": "bar"}, gcs.Config{"foo": "bar"}, true, }, { "equal-multiple-keys", gcs.Config{"foo": "bar", "baz": []string{"a", "b", "c"}, "blah": 123, "bool": true}, gcs.Config{"foo": "bar", "baz": []string{"a", "b", "c"}, "blah": 123, "bool": true}, true, }, { "equal-encrypt-bool-handling", gcs.Config{"encrypt": true}, gcs.Config{"encrypt": "true"}, true, }, { "equal-general-bool-handling", gcs.Config{"something": true, "encrypt": true}, gcs.Config{"something": "true", "encrypt": "true"}, true, }, { "equal-ignore-gcs-labels", gcs.Config{"foo": "bar", "gcs_bucket_labels": []map[string]string{{"foo": "bar"}}}, gcs.Config{"foo": "bar"}, true, }, { "unequal-values", gcs.Config{"foo": "bar"}, gcs.Config{"foo": "different"}, false, }, { "unequal-non-empty-cfg-nil", gcs.Config{"foo": "bar"}, nil, false, }, { "unequal-general-bool-handling", gcs.Config{"something": true}, gcs.Config{"something": "false"}, false, }, { "equal-null-ignored", gcs.Config{"something": "foo"}, gcs.Config{"something": "foo", "ignored-because-null": nil}, true, }, { "terragrunt-only-configs-remain-intact", gcs.Config{"something": "foo", "skip_bucket_creation": true}, gcs.Config{"something": "foo"}, true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := tc.cfg.IsEqual(tc.comparableCfg, logger) assert.Equal(t, tc.shouldBeEqual, actual) }) } } // TestParseExtendedGCSConfig_StringBoolCoercion verifies that boolean config values // passed as strings (e.g. from HCL ternary type unification) are correctly parsed. // See https://github.com/gruntwork-io/terragrunt/issues/5475 func TestParseExtendedGCSConfig_StringBoolCoercion(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config gcs.Config check func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) }{ { "skip-bucket-versioning-string-true", gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": "true", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.True(t, cfg.SkipBucketVersioning) }, }, { "skip-bucket-versioning-string-false", gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": "false", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.False(t, cfg.SkipBucketVersioning) }, }, { "skip-bucket-creation-string-true", gcs.Config{ "bucket": "my-bucket", "skip_bucket_creation": "true", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.True(t, cfg.SkipBucketCreation) }, }, { "enable-bucket-policy-only-string-true", gcs.Config{ "bucket": "my-bucket", "enable_bucket_policy_only": "true", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.True(t, cfg.EnableBucketPolicyOnly) }, }, { "native-bool-still-works", gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": true, }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.True(t, cfg.SkipBucketVersioning) }, }, { "empty-string-coerces-to-false", gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": "", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.False(t, cfg.SkipBucketVersioning) }, }, { "numeric-one-coerces-to-true", gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": "1", }, func(t *testing.T, cfg *gcs.ExtendedRemoteStateConfigGCS) { t.Helper() assert.True(t, cfg.SkipBucketVersioning) }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extGCSCfg, err := tc.config.ParseExtendedGCSConfig() require.NoError(t, err) tc.check(t, extGCSCfg) }) } } // TestParseExtendedGCSConfig_InvalidStringBool verifies invalid string values // for bool fields are rejected (e.g. "maybe" is not a valid bool). func TestParseExtendedGCSConfig_InvalidStringBool(t *testing.T) { t.Parallel() cfg := gcs.Config{ "bucket": "my-bucket", "skip_bucket_versioning": "maybe", } _, err := cfg.ParseExtendedGCSConfig() require.Error(t, err) } ================================================ FILE: internal/remotestate/backend/gcs/errors.go ================================================ package gcs import "fmt" type MissingRequiredGCSRemoteStateConfig string func (configName MissingRequiredGCSRemoteStateConfig) Error() string { return "Missing required GCS remote state configuration " + string(configName) } type MaxRetriesWaitingForGCSBucketExceeded string func (err MaxRetriesWaitingForGCSBucketExceeded) Error() string { return fmt.Sprintf("Exceeded max retries (%d) waiting for bucket GCS bucket %s", maxRetriesWaitingForGcsBucket, string(err)) } ================================================ FILE: internal/remotestate/backend/gcs/remote_state_config.go ================================================ package gcs import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/gcphelper" ) // These are settings that can appear in the remote_state config that are ONLY used by Terragrunt and NOT forwarded // to the underlying Terraform backend configuration. var terragruntOnlyConfigs = []string{ "project", "location", "gcs_bucket_labels", "skip_bucket_versioning", "skip_bucket_creation", "enable_bucket_policy_only", } /* ExtendedRemoteStateConfigGCS is a struct that contains the GCS specific configuration options. * * We use this construct to separate the config key 'gcs_bucket_labels' from the others, as they * are specific to the gcs backend, but only used by terragrunt to tag the gcs bucket in case it * has to create them. */ type ExtendedRemoteStateConfigGCS struct { GCSBucketLabels map[string]string `mapstructure:"gcs_bucket_labels"` Project string `mapstructure:"project"` Location string `mapstructure:"location"` RemoteStateConfigGCS RemoteStateConfigGCS `mapstructure:",squash"` SkipBucketVersioning bool `mapstructure:"skip_bucket_versioning"` SkipBucketCreation bool `mapstructure:"skip_bucket_creation"` EnableBucketPolicyOnly bool `mapstructure:"enable_bucket_policy_only"` } // Validate validates the configuration for GCS remote state. func (cfg *ExtendedRemoteStateConfigGCS) Validate() error { var bucketName = cfg.RemoteStateConfigGCS.Bucket // Bucket is always a required configuration parameter when not skipping bucket creation // so we check it here to make sure we have handle to the bucket // before we start validating the rest of the configuration. if bucketName == "" { return errors.New(MissingRequiredGCSRemoteStateConfig("bucket")) } return nil } // RemoteStateConfigGCS is a representation of the configuration // options available for GCS remote state. type RemoteStateConfigGCS struct { Bucket string `mapstructure:"bucket"` Credentials string `mapstructure:"credentials"` AccessToken string `mapstructure:"access_token"` Prefix string `mapstructure:"prefix"` Path string `mapstructure:"path"` EncryptionKey string `mapstructure:"encryption_key"` ImpersonateServiceAccount string `mapstructure:"impersonate_service_account"` ImpersonateServiceAccountDelegates []string `mapstructure:"impersonate_service_account_delegates"` } // CacheKey returns a unique key for the given GCS config that can be used to cache the initialization. func (cfg *RemoteStateConfigGCS) CacheKey() string { return cfg.Bucket } // GetGCPSessionConfig returns a GcpSessionConfig from the ExtendedRemoteStateConfigGCS configuration. func (cfg *ExtendedRemoteStateConfigGCS) GetGCPSessionConfig() *gcphelper.GCPSessionConfig { return &gcphelper.GCPSessionConfig{ Credentials: cfg.RemoteStateConfigGCS.Credentials, AccessToken: cfg.RemoteStateConfigGCS.AccessToken, ImpersonateServiceAccount: cfg.RemoteStateConfigGCS.ImpersonateServiceAccount, ImpersonateServiceAccountDelegates: cfg.RemoteStateConfigGCS.ImpersonateServiceAccountDelegates, } } ================================================ FILE: internal/remotestate/backend/normalize.go ================================================ package backend import ( "maps" "reflect" "strconv" "strings" ) // NormalizeBoolValues converts string boolean values ("true"/"false") in the // config map back to native Go bools. HCL ternary type unification can convert // bools to strings, which causes generated backend blocks to contain quoted // literals that Terraform/OpenTofu rejects. // // The target parameter should be a pointer to the config struct (e.g. // &ExtendedRemoteStateConfigS3{}); its mapstructure tags determine which // keys are boolean fields. func NormalizeBoolValues(m Config, target any) Config { boolKeys := collectBoolKeys(reflect.TypeOf(target)) if len(boolKeys) == 0 { return m } normalized := make(Config) maps.Copy(normalized, m) for key, val := range normalized { strVal, ok := val.(string) if _, isBool := boolKeys[key]; !ok || !isBool { continue } if boolVal, err := strconv.ParseBool(strVal); err == nil { normalized[key] = boolVal } } return normalized } // collectBoolKeys walks a struct type via reflection, reading mapstructure tags // to build a set of config key names that map to bool fields. func collectBoolKeys(t reflect.Type) map[string]struct{} { if t == nil { return nil } for t.Kind() == reflect.Ptr { t = t.Elem() } if t.Kind() != reflect.Struct { return nil } keys := make(map[string]struct{}) for i := range t.NumField() { field := t.Field(i) tag := field.Tag.Get("mapstructure") // Handle squashed embedded structs if tag == ",squash" || (tag == "" && field.Anonymous) { maps.Copy(keys, collectBoolKeys(field.Type)) continue } if key, ok := collectFieldBoolKey(&field, tag); ok { keys[key] = struct{}{} } } return keys } // collectFieldBoolKey returns the config key name for a bool field, // or empty string and false if the field is not a bool. func collectFieldBoolKey(field *reflect.StructField, tag string) (string, bool) { if tag == "" || tag == "-" { return "", false } key, _, _ := strings.Cut(tag, ",") if key == "" { return "", false } fieldType := field.Type if fieldType.Kind() == reflect.Ptr { fieldType = fieldType.Elem() } return key, fieldType.Kind() == reflect.Bool } ================================================ FILE: internal/remotestate/backend/normalize_test.go ================================================ package backend_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/stretchr/testify/assert" ) type testConfig struct { Name string `mapstructure:"name"` Encrypt bool `mapstructure:"encrypt"` Enabled bool `mapstructure:"enabled"` } type testConfigWithSquash struct { Region string `mapstructure:"region"` Inner testConfig `mapstructure:",squash"` Verbose bool `mapstructure:"verbose"` } func TestNormalizeBoolValues_StringToTrue(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "true", "name": "test"} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Equal(t, true, result["encrypt"]) assert.Equal(t, "test", result["name"]) } func TestNormalizeBoolValues_StringToFalse(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "false"} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Equal(t, false, result["encrypt"]) } func TestNormalizeBoolValues_NativeBoolUnchanged(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": true, "enabled": false} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Equal(t, true, result["encrypt"]) assert.Equal(t, false, result["enabled"]) } func TestNormalizeBoolValues_NonBoolStringUntouched(t *testing.T) { t.Parallel() m := backend.Config{"name": "true", "encrypt": "true"} result := backend.NormalizeBoolValues(m, &testConfig{}) // "name" is a string field in the struct, should NOT be converted assert.Equal(t, "true", result["name"]) // "encrypt" is a bool field, should be converted assert.Equal(t, true, result["encrypt"]) } func TestNormalizeBoolValues_InvalidBoolStringLeftAsIs(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "maybe"} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Equal(t, "maybe", result["encrypt"]) } func TestNormalizeBoolValues_SquashedStructFields(t *testing.T) { t.Parallel() m := backend.Config{ "encrypt": "true", "enabled": "false", "verbose": "true", "name": "test", "region": "us-east-1", } result := backend.NormalizeBoolValues(m, &testConfigWithSquash{}) assert.Equal(t, true, result["encrypt"]) assert.Equal(t, false, result["enabled"]) assert.Equal(t, true, result["verbose"]) assert.Equal(t, "test", result["name"]) assert.Equal(t, "us-east-1", result["region"]) } func TestNormalizeBoolValues_OriginalMapUnmutated(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "true"} _ = backend.NormalizeBoolValues(m, &testConfig{}) // Original map should still have string assert.Equal(t, "true", m["encrypt"]) } func TestNormalizeBoolValues_EmptyMap(t *testing.T) { t.Parallel() m := backend.Config{} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Empty(t, result) } type testConfigWithPtrBool struct { Encrypt *bool `mapstructure:"encrypt"` Optional *bool `mapstructure:"optional"` Name string `mapstructure:"name"` } func TestNormalizeBoolValues_PtrBoolFields(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "true", "optional": "false", "name": "test"} result := backend.NormalizeBoolValues(m, &testConfigWithPtrBool{}) assert.Equal(t, true, result["encrypt"]) assert.Equal(t, false, result["optional"]) assert.Equal(t, "test", result["name"]) } func TestNormalizeBoolValues_NilMap(t *testing.T) { t.Parallel() result := backend.NormalizeBoolValues(nil, &testConfig{}) assert.Empty(t, result) } func TestNormalizeBoolValues_NilTarget(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "true", "name": "test"} result := backend.NormalizeBoolValues(m, nil) // With nil target, no bool keys are detected, so values are returned as-is assert.Equal(t, "true", result["encrypt"]) assert.Equal(t, "test", result["name"]) } func TestNormalizeBoolValues_NumericBoolStrings(t *testing.T) { t.Parallel() m := backend.Config{"encrypt": "1", "enabled": "0"} result := backend.NormalizeBoolValues(m, &testConfig{}) assert.Equal(t, true, result["encrypt"]) assert.Equal(t, false, result["enabled"]) } ================================================ FILE: internal/remotestate/backend/s3/backend.go ================================================ // Package s3 represents AWS S3 backend for interacting with remote state. package s3 import ( "context" "fmt" "path" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/log" ) const BackendName = "s3" var _ backend.Backend = new(Backend) type Backend struct { *backend.CommonBackend } func NewBackend() *Backend { return &Backend{ CommonBackend: backend.NewCommonBackend(BackendName), } } // NeedsBootstrap returns true if the remote state S3 bucket specified in the given config needs to be bootstrapped. // // Returns true if: // // 1. Any of the existing backend settings are different than the current config // 2. The configured S3 bucket or DynamoDB table does not exist func (backend *Backend) NeedsBootstrap(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) (bool, error) { cfg := Config(backendConfig).Normalize(l) extS3Cfg, err := cfg.ExtendedS3Config(l) if err != nil { return false, err } client, err := NewClient(ctx, l, extS3Cfg, opts) if err != nil { return false, err } var ( bucketName = extS3Cfg.RemoteStateConfigS3.Bucket tableName = extS3Cfg.RemoteStateConfigS3.GetLockTableName() ) if exists, err := client.DoesS3BucketExist(ctx, bucketName); err != nil || !exists { return true, err } if tableName != "" { if exists, err := client.DoesLockTableExistAndIsActive(ctx, tableName); err != nil || !exists { return true, err } } return false, nil } // Bootstrap the remote state S3 bucket specified in the given config. This function will validate the config // parameters, create the S3 bucket if it doesn't already exist, and check that versioning is enabled. func (backend *Backend) Bootstrap( ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options, ) error { extS3Cfg, err := Config(backendConfig).ExtendedS3Config(l) if err != nil { return err } var ( s3Cfg = &extS3Cfg.RemoteStateConfigS3 bucketName = s3Cfg.Bucket ) // ensure that only one goroutine can initialize bucket mu := backend.GetBucketMutex(bucketName) mu.Lock() defer mu.Unlock() if backend.IsConfigInited(s3Cfg) { l.Debugf( "%s bucket %s has already been confirmed to be initialized, skipping initialization checks", backend.Name(), bucketName, ) return nil } client, err := NewClient(ctx, l, extS3Cfg, opts) if err != nil { return err } if err := client.CreateS3BucketIfNecessary(ctx, l, bucketName, opts); err != nil { return err } if !extS3Cfg.DisableBucketUpdate { if err := client.UpdateS3BucketIfNecessary(ctx, l, bucketName, opts); err != nil { return err } } if !extS3Cfg.SkipBucketVersioning { if _, err := client.CheckIfVersioningEnabled(ctx, l, bucketName); err != nil { return err } } if tableName := extS3Cfg.RemoteStateConfigS3.GetLockTableName(); tableName != "" { if err := client.CreateLockTableIfNecessary(ctx, l, tableName, extS3Cfg.DynamotableTags); err != nil { return err } if extS3Cfg.EnableLockTableSSEncryption { if err := client.UpdateLockTableSetSSEncryptionOnIfNecessary(ctx, l, tableName); err != nil { return err } } } backend.MarkConfigInited(s3Cfg) return nil } // IsVersionControlEnabled returns true if version control for s3 bucket is enabled. func (backend *Backend) IsVersionControlEnabled(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) (bool, error) { extS3Cfg, err := Config(backendConfig).ExtendedS3Config(l) if err != nil { return false, err } var bucketName = extS3Cfg.RemoteStateConfigS3.Bucket client, err := NewClient(ctx, l, extS3Cfg, opts) if err != nil { return false, err } return client.CheckIfVersioningEnabled(ctx, l, bucketName) } // Migrate copies the s3 bucket object located at src config to dst config and deletes the src object. // Creates a new DynamoDB table item for dst config and deletes the table item from the src config. func (backend *Backend) Migrate(ctx context.Context, l log.Logger, srcBackendConfig, dstBackendConfig backend.Config, opts *backend.Options) error { srcExtS3Cfg, err := Config(srcBackendConfig).ExtendedS3Config(l) if err != nil { return err } dstExtS3Cfg, err := Config(dstBackendConfig).ExtendedS3Config(l) if err != nil { return err } var ( srcBucketName = srcExtS3Cfg.RemoteStateConfigS3.Bucket srcBucketKey = srcExtS3Cfg.RemoteStateConfigS3.Key srcTableName = srcExtS3Cfg.RemoteStateConfigS3.GetLockTableName() srcTableKey = path.Join(srcBucketName, srcBucketKey+stateIDSuffix) dstBucketName = dstExtS3Cfg.RemoteStateConfigS3.Bucket dstBucketKey = dstExtS3Cfg.RemoteStateConfigS3.Key dstTableName = dstExtS3Cfg.RemoteStateConfigS3.GetLockTableName() dstTableKey = path.Join(dstBucketName, dstBucketKey+stateIDSuffix) ) client, err := NewClient(ctx, l, srcExtS3Cfg, opts) if err != nil { return err } if err = client.MoveS3ObjectIfNecessary(ctx, l, srcBucketName, srcBucketKey, dstBucketName, dstBucketKey); err != nil { return err } if dstTableName != "" { if err := client.CreateTableItemIfNecessary(ctx, l, dstTableName, dstTableKey); err != nil { return err } } if srcTableName != "" { return client.DeleteTableItemIfNecessary(ctx, l, srcTableName, srcTableKey) } return nil } // Delete deletes the remote state specified in the given config. func (backend *Backend) Delete(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) error { extS3Cfg, err := Config(backendConfig).ExtendedS3Config(l) if err != nil { return err } var ( bucketName = extS3Cfg.RemoteStateConfigS3.Bucket bucketKey = extS3Cfg.RemoteStateConfigS3.Key tableName = extS3Cfg.RemoteStateConfigS3.GetLockTableName() ) client, err := NewClient(ctx, l, extS3Cfg, opts) if err != nil { return err } if tableName != "" { tableKey := path.Join(bucketName, bucketKey+stateIDSuffix) prompt := fmt.Sprintf("DynamoDB table %s key %s will be deleted. Do you want to continue?", tableName, tableKey) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { if err := client.DeleteTableItemIfNecessary(ctx, l, tableName, tableKey); err != nil { return err } } } prompt := fmt.Sprintf("S3 bucket %s key %s will be deleted. Do you want to continue?", bucketName, bucketKey) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { return client.DeleteS3ObjectIfNecessary(ctx, l, bucketName, bucketKey) } return nil } // DeleteBucket deletes the entire bucket specified in the given config. func (backend *Backend) DeleteBucket(ctx context.Context, l log.Logger, backendConfig backend.Config, opts *backend.Options) error { extS3Cfg, err := Config(backendConfig).ExtendedS3Config(l) if err != nil { return err } client, err := NewClient(ctx, l, extS3Cfg, opts) if err != nil { return err } var ( bucketName = extS3Cfg.RemoteStateConfigS3.Bucket tableName = extS3Cfg.RemoteStateConfigS3.GetLockTableName() ) if tableName != "" { prompt := fmt.Sprintf("DynamoDB table %s will be completely deleted. Do you want to continue?", tableName) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { if err := client.DeleteTableIfNecessary(ctx, l, tableName); err != nil { return err } } } prompt := fmt.Sprintf("S3 bucket %s will be completely deleted. Do you want to continue?", bucketName) if yes, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter); err != nil { return err } else if yes { return client.DeleteS3BucketIfNecessary(ctx, l, bucketName) } return nil } func (backend *Backend) GetTFInitArgs(config backend.Config) map[string]any { return Config(config).GetTFInitArgs() } ================================================ FILE: internal/remotestate/backend/s3/backend_test.go ================================================ package s3_test import ( "testing" backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/stretchr/testify/assert" ) func TestBackend_GetTFInitArgs(t *testing.T) { t.Parallel() remoteBackend := s3backend.NewBackend() testCases := []struct { //nolint: govet name string config backend.Config expected map[string]any shouldBeEqual bool }{ { "empty-no-values", backend.Config{}, map[string]any{}, true, }, { "valid-s3-configuration-keys", backend.Config{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", }, map[string]any{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", }, true, }, { "terragrunt-keys-filtered", backend.Config{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "skip_credentials_validation": true, "s3_bucket_tags": map[string]string{}, }, map[string]any{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "skip_credentials_validation": true, }, true, }, { "empty-no-values-all-terragrunt-keys-filtered", backend.Config{ "s3_bucket_tags": map[string]string{}, "dynamodb_table_tags": map[string]string{}, "accesslogging_bucket_tags": map[string]string{}, "skip_bucket_versioning": true, "skip_bucket_ssencryption": false, "skip_bucket_root_access": false, "skip_bucket_enforced_tls": false, "skip_bucket_public_access_blocking": false, "disable_bucket_update": true, "enable_lock_table_ssencryption": true, "disable_aws_client_checksums": false, "accesslogging_bucket_name": "test", "accesslogging_target_object_partition_date_source": "EventTime", "accesslogging_target_prefix": "test", "skip_accesslogging_bucket_acl": false, "skip_accesslogging_bucket_enforced_tls": false, "skip_accesslogging_bucket_public_access_blocking": false, "skip_accesslogging_bucket_ssencryption": false, }, map[string]any{}, true, }, { "lock-table-replaced-with-dynamodb-table", backend.Config{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "lock_table": "xyzzy", }, map[string]any{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "dynamodb_table": "xyzzy", }, true, }, { "dynamodb-table-not-replaced-with-lock-table", backend.Config{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "dynamodb_table": "xyzzy", }, map[string]any{ "bucket": "foo", "encrypt": "bar", "key": "baz", "region": "quux", "lock_table": "xyzzy", }, false, }, { "assume-role", backend.Config{ "bucket": "foo", "assume_role": map[string]any{ "role_arn": "arn:aws:iam::123:role/role", "external_id": "123", "session_name": "qwe", }, }, map[string]any{ "bucket": "foo", "assume_role": "{external_id=\"123\",role_arn=\"arn:aws:iam::123:role/role\",session_name=\"qwe\"}", }, true, }, { "use-lockfile-native-s3-locking", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": true, }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": true, }, true, }, { "use-lockfile-false", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": false, }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": false, }, true, }, { "dual-locking-dynamodb-and-s3", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "dynamodb_table": "my-lock-table", "use_lockfile": true, }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "dynamodb_table": "my-lock-table", "use_lockfile": true, }, true, }, { "string-bool-use-lockfile-true", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": "true", }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": true, }, true, }, { "string-bool-use-lockfile-false", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": "false", }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "use_lockfile": false, }, true, }, { "string-bool-encrypt-and-use-lockfile", backend.Config{ "bucket": "foo", "key": "bar", "region": "us-east-1", "encrypt": "true", "use_lockfile": "true", }, map[string]any{ "bucket": "foo", "key": "bar", "region": "us-east-1", "encrypt": true, "use_lockfile": true, }, true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := remoteBackend.GetTFInitArgs(tc.config) if !tc.shouldBeEqual { assert.NotEqual(t, tc.expected, actual) return } assert.Equal(t, tc.expected, actual) }) } } ================================================ FILE: internal/remotestate/backend/s3/client.go ================================================ package s3 import ( "context" "fmt" "path" "slices" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" dynamodbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( SidRootPolicy = "RootAccess" SidEnforcedTLSPolicy = "EnforcedTLS" s3TimeBetweenRetries = 5 * time.Second s3MaxRetries = 3 s3SleepBetweenRetries = 10 * time.Second maxRetriesWaitingForS3Bucket = 12 sleepBetweenRetriesWaitingForS3Bucket = 5 * time.Second // To enable access logging in an S3 bucket, you must grant WRITE and READ_ACP permissions to the Log Delivery Group, // which is represented by the following URI. For more info, see: // https://docs.aws.amazon.com/AmazonS3/latest/dev/enable-logging-programming.html s3LogDeliveryGranteeURI = "http://acs.amazonaws.com/groups/s3/LogDelivery" // DynamoDB only allows 10 table creates/deletes simultaneously. To ensure we don't hit this error, especially when // running many automated tests in parallel, we use a counting semaphore dynamoParallelOperations = 10 // AttrLockID is the name of the primary key for the lock table in DynamoDB. // OpenTofu/Terraform requires the DynamoDB table to have a primary key with this name AttrLockID = "LockID" // stateIDSuffix is last saved serial in tablestore with this suffix for consistency checks. stateIDSuffix = "-md5" // MaxRetriesWaitingForTableToBeActive is the maximum number of times we // will retry waiting for a table to be active. // // Default is to retry for up to 5 minutes MaxRetriesWaitingForTableToBeActive = 30 // SleepBetweenTableStatusChecks is the amount of time we will sleep between // checks to see if a table is active. SleepBetweenTableStatusChecks = 10 * time.Second // DynamodbPayPerRequestBillingMode is the billing mode for DynamoDB tables that allows for pay-per-request billing // instead of provisioned capacity. DynamodbPayPerRequestBillingMode = "PAY_PER_REQUEST" sleepBetweenRetriesWaitingForEncryption = 20 * time.Second maxRetriesWaitingForEncryption = 15 ) var tableCreateDeleteSemaphore = NewCountingSemaphore(dynamoParallelOperations) type Client struct { *ExtendedRemoteStateConfigS3 s3Client *s3.Client dynamoClient *dynamodb.Client awsConfig aws.Config failIfBucketCreationRequired bool } func NewClient(ctx context.Context, l log.Logger, config *ExtendedRemoteStateConfigS3, opts *backend.Options) (*Client, error) { awsConfig := config.GetAwsSessionConfig() builder := awshelper.NewAWSConfigBuilder(). WithSessionConfig(awsConfig). WithEnv(opts.Env). WithIAMRoleOptions(opts.IAMRoleOptions) cfg, err := builder.Build(ctx, l) if err != nil { return nil, errors.New(err) } if !config.SkipCredentialsValidation { if err = awshelper.ValidateAwsConfig(ctx, &cfg); err != nil { return nil, err } } s3Client, err := builder.BuildS3Client(ctx, l) if err != nil { return nil, errors.New(err) } dynamoDBClient := dynamodb.NewFromConfig(cfg) if awsConfig.CustomDynamoDBEndpoint != "" { dynamoDBClient = dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) { o.BaseEndpoint = aws.String(awsConfig.CustomDynamoDBEndpoint) }) } client := &Client{ ExtendedRemoteStateConfigS3: config, s3Client: s3Client, dynamoClient: dynamoDBClient, awsConfig: cfg, failIfBucketCreationRequired: opts.FailIfBucketCreationRequired, } return client, nil } // CreateS3BucketIfNecessary prompts the user to create the given bucket if it doesn't already exist and if the user // confirms, creates the bucket and enables versioning for it. func (client *Client) CreateS3BucketIfNecessary(ctx context.Context, l log.Logger, bucketName string, opts *backend.Options) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot create S3 bucket if necessary") } cfg := &client.ExtendedRemoteStateConfigS3.RemoteStateConfigS3 if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, cfg.Bucket); err != nil || exists { return err } if opts.FailIfBucketCreationRequired { return backend.BucketCreationNotAllowed(bucketName) } prompt := fmt.Sprintf("Remote state S3 bucket %s does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", bucketName) shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { return err } if shouldCreateBucket { // Creating the S3 bucket occasionally fails with eventual consistency errors: e.g., the S3 HeadBucket // operation says the bucket exists, but a subsequent call to enable versioning on that bucket fails with // the error "NoSuchBucket: The specified bucket does not exist." Therefore, when creating and configuring // the S3 bucket, we do so in a retry loop with a sleep between retries that will hopefully work around the // eventual consistency issues. Each S3 operation should be idempotent, so redoing steps that have already // been performed should be a no-op. description := "Create S3 bucket with retry " + bucketName return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { err := client.CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(ctx, l, opts) if err != nil { if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} } return nil }) } return nil } func (client *Client) UpdateS3BucketIfNecessary(ctx context.Context, l log.Logger, bucketName string, opts *backend.Options) error { if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, bucketName); err != nil { return err } else if !exists && opts.FailIfBucketCreationRequired { return backend.BucketCreationNotAllowed(bucketName) } needsUpdate, bucketUpdatesRequired, err := client.checkIfS3BucketNeedsUpdate(ctx, l, bucketName) if err != nil { return err } if !needsUpdate { l.Debug("S3 bucket is already up to date") return nil } prompt := fmt.Sprintf("Remote state S3 bucket %s is out of date. Would you like Terragrunt to update it?", bucketName) shouldUpdateBucket, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { return err } if !shouldUpdateBucket { return nil } if bucketUpdatesRequired.Versioning { if client.SkipBucketVersioning { l.Debugf("Versioning is disabled for the remote state S3 bucket %s using 'skip_bucket_versioning' config.", bucketName) } else if err := client.EnableVersioningForS3Bucket(ctx, l, bucketName); err != nil { return err } } if bucketUpdatesRequired.SSEEncryption { msg := fmt.Sprintf("Encryption is not enabled on the S3 remote state bucket %s. Terraform state files may contain secrets, so we STRONGLY recommend enabling encryption!", bucketName) if client.SkipBucketSSEncryption { l.Debug(msg) l.Debugf("Server-Side Encryption enabling is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_ssencryption' config.", bucketName) return nil } else { l.Warn(msg) } l.Infof("Enabling Server-Side Encryption for the remote state AWS S3 bucket %s.", bucketName) if err := client.EnableSSEForS3BucketWide(ctx, l, bucketName, client.FetchEncryptionAlgorithm()); err != nil { l.Errorf("Failed to enable Server-Side Encryption for the remote state AWS S3 bucket %s: %v", bucketName, err) return err } l.Infof("Successfully enabled Server-Side Encryption for the remote state AWS S3 bucket %s.", bucketName) } if bucketUpdatesRequired.RootAccess { if client.SkipBucketRootAccess { l.Debugf("Root access is disabled for the remote state S3 bucket %s using 'skip_bucket_root_access' config.", bucketName) } else if err := client.EnableRootAccesstoS3Bucket(ctx, l); err != nil { return err } } if bucketUpdatesRequired.EnforcedTLS { if client.SkipBucketEnforcedTLS { l.Debugf("Enforced TLS is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_enforced_tls' config.", bucketName) } else if err := client.EnableEnforcedTLSAccesstoS3Bucket(ctx, l, bucketName); err != nil { return err } } if bucketUpdatesRequired.AccessLogging { if client.SkipBucketAccessLogging { l.Debugf("Access logging is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_access_logging' config.", bucketName) } else { if client.AccessLoggingBucketName != "" { if err := client.configureAccessLogBucket(ctx, l, opts); err != nil { return err } } else { l.Debugf("Access Logging is disabled for the remote state AWS S3 bucket %s", bucketName) } } } if bucketUpdatesRequired.PublicAccess { if client.SkipBucketPublicAccessBlocking { l.Debugf("Public access blocking is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_public_access_blocking' config.", bucketName) } else if err := client.EnablePublicAccessBlockingForS3Bucket(ctx, l, bucketName); err != nil { return err } } return nil } // configureAccessLogBucket - configure access log bucket. func (client *Client) configureAccessLogBucket(ctx context.Context, l log.Logger, opts *backend.Options) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot configure access log bucket") } cfg := &client.ExtendedRemoteStateConfigS3.RemoteStateConfigS3 l.Debugf("Enabling bucket-wide Access Logging on AWS S3 bucket %s - using as TargetBucket %s", cfg.Bucket, client.AccessLoggingBucketName) if err := client.CreateLogsS3BucketIfNecessary(ctx, l, client.AccessLoggingBucketName, opts); err != nil { l.Errorf("Could not create logs bucket %s for AWS S3 bucket %s\n%s", client.AccessLoggingBucketName, cfg.Bucket, err.Error()) return err } if !client.SkipAccessLoggingBucketPublicAccessBlocking { if err := client.EnablePublicAccessBlockingForS3Bucket(ctx, l, client.AccessLoggingBucketName); err != nil { l.Errorf("Could not enable public access blocking on %s\n%s", client.AccessLoggingBucketName, err.Error()) return err } } if err := client.EnableAccessLoggingForS3BucketWide(ctx, l); err != nil { l.Errorf("Could not enable access logging on %s\n%s", cfg.Bucket, err.Error()) return err } if !client.SkipAccessLoggingBucketSSEncryption { if err := client.EnableSSEForS3BucketWide(ctx, l, client.AccessLoggingBucketName, string(types.ServerSideEncryptionAes256)); err != nil { l.Errorf("Could not enable encryption on %s\n%s", client.AccessLoggingBucketName, err.Error()) return err } } if !client.SkipAccessLoggingBucketEnforcedTLS { if err := client.EnableEnforcedTLSAccesstoS3Bucket(ctx, l, client.AccessLoggingBucketName); err != nil { l.Errorf("Could not enable TLS access on %s\n%s", client.AccessLoggingBucketName, err.Error()) return err } } if client.SkipBucketVersioning { l.Debugf("Versioning is disabled for the remote state S3 bucket %s using 'skip_bucket_versioning' config.", client.AccessLoggingBucketName) } else if err := client.EnableVersioningForS3Bucket(ctx, l, client.AccessLoggingBucketName); err != nil { return err } return nil } type S3BucketUpdatesRequired struct { Versioning bool SSEEncryption bool RootAccess bool EnforcedTLS bool AccessLogging bool PublicAccess bool } func (client *Client) checkIfS3BucketNeedsUpdate(ctx context.Context, l log.Logger, bucketName string) (bool, S3BucketUpdatesRequired, error) { var ( updates []string toUpdate S3BucketUpdatesRequired ) if !client.SkipBucketVersioning { enabled, err := client.CheckIfVersioningEnabled(ctx, l, bucketName) if err != nil { return false, toUpdate, err } if !enabled { toUpdate.Versioning = true updates = append(updates, "Bucket Versioning") } } if !client.SkipBucketSSEncryption { matches, err := client.checkIfSSEForS3MatchesConfig(ctx, bucketName) if err != nil { return false, toUpdate, err } if !matches { toUpdate.SSEEncryption = true updates = append(updates, "Bucket Server-Side Encryption") } } if !client.SkipBucketRootAccess { enabled, err := client.checkIfBucketRootAccess(ctx, l, bucketName) if err != nil { return false, toUpdate, err } if !enabled { toUpdate.RootAccess = true updates = append(updates, "Bucket Root Access") } } if !client.SkipBucketEnforcedTLS { enabled, err := client.checkIfBucketEnforcedTLS(ctx, l, bucketName) if err != nil { return false, toUpdate, err } if !enabled { toUpdate.EnforcedTLS = true updates = append(updates, "Bucket Enforced TLS") } } if !client.SkipBucketAccessLogging && client.AccessLoggingBucketName != "" { enabled, err := client.checkS3AccessLoggingConfiguration(ctx, bucketName) if err != nil { return false, toUpdate, err } if !enabled { toUpdate.AccessLogging = true updates = append(updates, "Bucket Access Logging") } } if !client.SkipBucketPublicAccessBlocking { enabled, err := client.checkIfS3PublicAccessBlockingEnabled(ctx, bucketName) if err != nil { return false, toUpdate, err } if !enabled { toUpdate.PublicAccess = true updates = append(updates, "Bucket Public Access Blocking") } } // show update message if any of the above configs are not set if len(updates) > 0 { l.Warnf("The remote state S3 bucket %s needs to be updated:", bucketName) for _, update := range updates { l.Warnf(" - %s", update) } return true, toUpdate, nil } return false, toUpdate, nil } // CheckIfVersioningEnabled checks if versioning is enabled for the S3 bucket specified in the given config and warn the user if it is not func (client *Client) CheckIfVersioningEnabled(ctx context.Context, l log.Logger, bucketName string) (bool, error) { if exists, err := client.DoesS3BucketExist(ctx, bucketName); err != nil { return false, err } else if !exists { return false, backend.NewBucketDoesNotExistError(bucketName) } l.Debugf("Verifying AWS S3 bucket versioning %s", bucketName) res, err := client.s3Client.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: aws.String(bucketName)}) if err != nil { return false, errors.New(err) } // NOTE: There must be a bug in the AWS SDK since res == nil when versioning is not enabled. In the future, // check the AWS SDK for updates to see if we can remove "res == nil ||". if res == nil || res.Status != types.BucketVersioningStatusEnabled { l.Warnf("Versioning is not enabled for the remote state S3 bucket %s. We recommend enabling versioning so that you can roll back to previous versions of your OpenTofu/Terraform state in case of error.", bucketName) return false, nil } return true, nil } // CreateS3BucketWithVersioningSSEncryptionAndAccessLogging creates the given S3 bucket and enable versioning for it. func (client *Client) CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(ctx context.Context, l log.Logger, opts *backend.Options) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot create S3 bucket") } cfg := &client.ExtendedRemoteStateConfigS3.RemoteStateConfigS3 l.Debugf("Create S3 bucket %s with versioning, SSE encryption, and access logging.", cfg.Bucket) err := client.CreateS3Bucket(ctx, l, cfg.Bucket, CreateS3BucketOpts{Tags: client.S3BucketTags}) if err != nil { if accessError := client.checkBucketAccess(ctx, cfg.Bucket, cfg.Key); accessError != nil { return accessError } if isBucketAlreadyOwnedByYouError(err) { l.Debugf("Looks like you're already creating bucket %s at the same time. Will not attempt to create it again.", cfg.Bucket) return client.WaitUntilS3BucketExists(ctx, l, cfg.Bucket) } return err } if err := client.WaitUntilS3BucketExists(ctx, l, cfg.Bucket); err != nil { return err } if client.SkipBucketRootAccess { l.Debugf("Root access is disabled for the remote state S3 bucket %s using 'skip_bucket_root_access' config.", cfg.Bucket) } else if err := client.EnableRootAccesstoS3Bucket(ctx, l); err != nil { return err } if client.SkipBucketEnforcedTLS { l.Debugf("TLS enforcement is disabled for the remote state S3 bucket %s using 'skip_bucket_enforced_tls' config.", cfg.Bucket) } else if err := client.EnableEnforcedTLSAccesstoS3Bucket(ctx, l, cfg.Bucket); err != nil { return err } if client.SkipBucketPublicAccessBlocking { l.Debugf("Public access blocking is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_public_access_blocking' config.", cfg.Bucket) } else if err := client.EnablePublicAccessBlockingForS3Bucket(ctx, l, cfg.Bucket); err != nil { return err } if err := client.TagS3Bucket(ctx, l); err != nil { return err } if client.SkipBucketVersioning { l.Debugf("Versioning is disabled for the remote state S3 bucket %s using 'skip_bucket_versioning' config.", cfg.Bucket) } else if err := client.EnableVersioningForS3Bucket(ctx, l, cfg.Bucket); err != nil { return err } if client.SkipBucketSSEncryption { l.Debugf("Server-Side Encryption is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_ssencryption' config.", cfg.Bucket) } else if err := client.EnableSSEForS3BucketWide(ctx, l, cfg.Bucket, client.FetchEncryptionAlgorithm()); err != nil { return err } if client.SkipBucketAccessLogging { l.Warnf("Terragrunt configuration option 'skip_bucket_accesslogging' is now deprecated. Access logging for the state bucket %s is disabled by default. To enable access logging for bucket %s, please provide property `accesslogging_bucket_name` in the terragrunt config file. For more details, please refer to the Terragrunt documentation.", cfg.Bucket, cfg.Bucket) } if client.AccessLoggingBucketName != "" { if err := client.configureAccessLogBucket(ctx, l, opts); err != nil { return err } } else { l.Debugf("Access Logging is disabled for the remote state AWS S3 bucket %s", cfg.Bucket) } if err := client.TagS3BucketAccessLogging(ctx, l); err != nil { return err } return nil } func (client *Client) CreateLogsS3BucketIfNecessary(ctx context.Context, l log.Logger, logsBucketName string, opts *backend.Options) error { if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, logsBucketName); err != nil || exists { return err } if client.failIfBucketCreationRequired { return backend.BucketCreationNotAllowed(logsBucketName) } prompt := fmt.Sprintf("Logs S3 bucket %s for the remote state does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", logsBucketName) shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { return err } if shouldCreateBucket { return client.CreateS3BucketWithRetry(ctx, l, logsBucketName, CreateS3BucketOpts{Tags: client.AccessLoggingBucketTags}) } return nil } func (client *Client) TagS3BucketAccessLogging(ctx context.Context, l log.Logger) error { if len(client.AccessLoggingBucketTags) == 0 { l.Debugf("No tags specified for bucket %s.", client.AccessLoggingBucketName) return nil } // There must be one entry in the list var tagsConverted = convertTags(client.AccessLoggingBucketTags) l.Debugf("Tagging S3 bucket with %s", client.AccessLoggingBucketTags) putBucketTaggingInput := s3.PutBucketTaggingInput{ Bucket: aws.String(client.AccessLoggingBucketName), Tagging: &types.Tagging{ TagSet: tagsConverted, }, } _, err := client.s3Client.PutBucketTagging(ctx, &putBucketTaggingInput) if err != nil { if handleS3TaggingMethodNotAllowed(err, l, "access logging bucket") { return nil } return errors.New(err) } l.Debugf("Tagged S3 bucket with %s", client.AccessLoggingBucketTags) return nil } // TagS3Bucket tags the S3 bucket with the tags specified in the config. func (client *Client) TagS3Bucket(ctx context.Context, l log.Logger) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot tag S3 bucket") } cfg := &client.ExtendedRemoteStateConfigS3.RemoteStateConfigS3 if len(client.S3BucketTags) == 0 { l.Debugf("No tags to apply to S3 bucket %s", cfg.Bucket) return nil } l.Debugf("Tagging S3 bucket %s with %s", cfg.Bucket, client.S3BucketTags) tagsConverted := convertTags(client.S3BucketTags) putBucketTaggingInput := s3.PutBucketTaggingInput{ Bucket: aws.String(cfg.Bucket), Tagging: &types.Tagging{ TagSet: tagsConverted, }, } _, err := client.s3Client.PutBucketTagging(ctx, &putBucketTaggingInput) if err != nil { if handleS3TaggingMethodNotAllowed(err, l, cfg.Bucket) { return nil } return errors.New(err) } l.Debugf("Tagged S3 bucket with %s", client.S3BucketTags) return nil } func convertTags(tags map[string]string) []types.Tag { var tagsConverted = make([]types.Tag, 0, len(tags)) for k, v := range tags { var tag = types.Tag{ Key: aws.String(k), Value: aws.String(v)} tagsConverted = append(tagsConverted, tag) } return tagsConverted } // WaitUntilS3BucketExists waits until the S3 bucket with the given name exists. // // AWS is eventually consistent, so after creating an S3 bucket, this method can be used to wait until the information // about that S3 bucket has propagated everywhere. func (client *Client) WaitUntilS3BucketExists(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Waiting for bucket %s to be created", bucketName) for retries := range maxRetriesWaitingForS3Bucket { if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, bucketName); err != nil { return err } else if exists { l.Debugf("S3 bucket %s created.", bucketName) return nil } else if retries < maxRetriesWaitingForS3Bucket-1 { l.Debugf("S3 bucket %s has not been created yet. Sleeping for %s and will check again.", bucketName, sleepBetweenRetriesWaitingForS3Bucket) time.Sleep(sleepBetweenRetriesWaitingForS3Bucket) } } return errors.New(MaxRetriesWaitingForS3BucketExceeded(bucketName)) } // CreateS3BucketOpts holds optional parameters for CreateS3Bucket. type CreateS3BucketOpts struct { // Tags to apply at bucket creation time via CreateBucketConfiguration.Tags. // This is required in environments where an AWS SCP or tag policy enforces // mandatory tags on s3:CreateBucket. Tags map[string]string } // CreateS3Bucket creates the S3 bucket specified in the given config. func (client *Client) CreateS3Bucket(ctx context.Context, l log.Logger, bucket string, opts ...CreateS3BucketOpts) error { if client.s3Client == nil { return errors.Errorf("S3 client is nil - cannot create S3 bucket %s", bucket) } l.Debugf("Creating S3 bucket %s", bucket) input := &s3.CreateBucketInput{ Bucket: aws.String(bucket), ObjectOwnership: types.ObjectOwnershipObjectWriter, } // For regions other than us-east-1, we need to specify the location constraint // to avoid IllegalLocationConstraintException region := client.awsConfig.Region if region != "us-east-1" && region != "" { l.Debugf("Creating S3 bucket %s in region %s", bucket, region) input.CreateBucketConfiguration = &types.CreateBucketConfiguration{ LocationConstraint: types.BucketLocationConstraint(region), } } if len(opts) > 0 && len(opts[0].Tags) > 0 { l.Debugf("Including %d tag(s) in CreateBucket request for %s", len(opts[0].Tags), bucket) sdkTags := convertTags(opts[0].Tags) if input.CreateBucketConfiguration == nil { input.CreateBucketConfiguration = &types.CreateBucketConfiguration{} } input.CreateBucketConfiguration.Tags = sdkTags } _, err := client.s3Client.CreateBucket(ctx, input) if err != nil { return errors.New(err) } l.Debugf("Created S3 bucket %s", bucket) return nil } // CreateS3BucketWithRetry creates an S3 bucket with full safeguards: // - Retry logic for transient errors // - Concurrent creation handling (BucketAlreadyOwnedByYou) // - Eventual consistency waiting after creation func (client *Client) CreateS3BucketWithRetry(ctx context.Context, l log.Logger, bucketName string, opts ...CreateS3BucketOpts) error { description := "Create S3 bucket '" + bucketName + "' with retry" return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { err := client.CreateS3Bucket(ctx, l, bucketName, opts...) if err != nil { if isBucketAlreadyOwnedByYouError(err) { l.Debugf("Looks like you're already creating bucket %s at the same time. Will not attempt to create it again.", bucketName) return client.WaitUntilS3BucketExists(ctx, l, bucketName) } if isBucketErrorRetriable(l, err) { return err } return util.FatalError{Underlying: err} } return client.WaitUntilS3BucketExists(ctx, l, bucketName) }) } // or is in progress. This usually happens when running many tests in parallel or xxx-all commands. func isBucketAlreadyOwnedByYouError(err error) bool { var apiErr smithy.APIError if errors.As(err, &apiErr) { return apiErr.ErrorCode() == "BucketAlreadyOwnedByYou" || apiErr.ErrorCode() == "OperationAborted" } return false } // isBucketErrorRetriable returns true if the error is temporary and can be retried. func isBucketErrorRetriable(l log.Logger, err error) bool { var apiErr smithy.APIError if errors.As(err, &apiErr) { unrecoverable := apiErr.ErrorCode() == "InternalError" || apiErr.ErrorCode() == "OperationAborted" || apiErr.ErrorCode() == "InvalidParameter" if !unrecoverable { l.Debugf( "Encountered AWS API error '%s' during bucket creation. Assuming it's retriable and will retry.", apiErr.ErrorCode(), ) } return unrecoverable } l.Debugf( "Encountered error '%s'. Assuming it's retriable and will retry.", err.Error(), ) return true } // EnableRootAccesstoS3Bucket adds a policy to allow root access to the bucket. func (client *Client) EnableRootAccesstoS3Bucket(ctx context.Context, l log.Logger) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot enable root access to S3 bucket") } if client.s3Client == nil { return errors.Errorf("S3 client is nil - cannot enable root access to S3 bucket") } // Access bucket name safely through defensive checking config := client.ExtendedRemoteStateConfigS3 bucket := config.RemoteStateConfigS3.Bucket if bucket == "" { return errors.Errorf("S3 bucket name is empty - cannot enable root access to S3 bucket") } l.Debugf("Enabling root access to S3 bucket %s", bucket) if client.awsConfig.Region == "" { return errors.Errorf("AWS config region is empty - cannot enable root access to S3 bucket %s", bucket) } accountID, err := awshelper.GetAWSAccountID(ctx, &client.awsConfig) if err != nil { return errors.Errorf("error getting AWS account ID %s for bucket %s: %w", accountID, bucket, err) } if accountID == "" { return errors.Errorf("AWS account ID is empty - cannot enable root access to S3 bucket %s", bucket) } partition, err := awshelper.GetAWSPartition(ctx, &client.awsConfig) if err != nil { return errors.Errorf("error getting AWS partition %s for bucket %s: %w", partition, bucket, err) } if partition == "" { return errors.Errorf("AWS partition is empty - cannot enable root access to S3 bucket %s", bucket) } var policyInBucket awshelper.Policy policyOutput, err := client.s3Client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) // If there's no policy, we need to create one if err != nil { l.Debugf("Policy not exists for bucket %s", bucket) } if policyOutput != nil && policyOutput.Policy != nil { l.Debugf("Policy already exists for bucket %s", bucket) policyInBucket, err = awshelper.UnmarshalPolicy(*policyOutput.Policy) if err != nil { return errors.Errorf("error unmarshalling policy for bucket %s: %w", bucket, err) } } // Ensure Statement is never nil to avoid nil pointer dereference if policyInBucket.Statement == nil { policyInBucket.Statement = []awshelper.Statement{} } // Iterate over statements to check if root policy already exists for _, statement := range policyInBucket.Statement { if statement.Sid == SidRootPolicy { l.Debugf("Policy for RootAccess already exists for bucket %s", bucket) return nil } } rootS3Policy := awshelper.Policy{ Version: "2012-10-17", Statement: []awshelper.Statement{ { Sid: SidRootPolicy, Effect: "Allow", Action: "s3:*", Resource: []string{ "arn:" + partition + ":s3:::" + bucket, "arn:" + partition + ":s3:::" + bucket + "/*", }, Principal: map[string][]string{ "AWS": { "arn:" + partition + ":iam::" + accountID + ":root", }, }, }, }, } // Append the root s3 policy to the existing policy in the bucket rootS3Policy.Statement = append(rootS3Policy.Statement, policyInBucket.Statement...) policy, err := awshelper.MarshalPolicy(rootS3Policy) if err != nil { return errors.Errorf("error marshalling policy for bucket %s: %w", bucket, err) } _, err = client.s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(string(policy)), }) if err != nil { return errors.Errorf("error putting policy for bucket %s: %w", bucket, err) } l.Debugf("Enabled root access to bucket %s", bucket) return nil } func (client *Client) EnableEnforcedTLSAccesstoS3Bucket(ctx context.Context, l log.Logger, bucket string) error { partition, err := awshelper.GetAWSPartition(ctx, &client.awsConfig) if err != nil { return errors.Errorf("error getting AWS partition %s for bucket %s: %w", partition, bucket, err) } policyOutput, err := client.s3Client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{ Bucket: aws.String(bucket), }) if err != nil { l.Debugf("Policy not exists for bucket %s", bucket) } var policyInBucket awshelper.Policy if policyOutput != nil && policyOutput.Policy != nil { policyInBucket, err = awshelper.UnmarshalPolicy(*policyOutput.Policy) if err != nil { return errors.Errorf("error unmarshalling policy for bucket %s: %w", bucket, err) } } // Ensure Statement is never nil to avoid nil pointer dereference if policyInBucket.Statement == nil { policyInBucket.Statement = []awshelper.Statement{} } for _, statement := range policyInBucket.Statement { if statement.Sid == SidEnforcedTLSPolicy { l.Debugf("Policy for EnforcedTLS already exists for bucket %s", bucket) return nil } } enforcedTLSPolicy := awshelper.Policy{ Version: "2012-10-17", Statement: []awshelper.Statement{ { Sid: SidEnforcedTLSPolicy, Effect: "Deny", Action: "s3:*", Resource: []string{ "arn:" + partition + ":s3:::" + bucket, "arn:" + partition + ":s3:::" + bucket + "/*", }, Principal: map[string][]string{"*": {"*"}}, Condition: &map[string]any{ "Bool": map[string]any{"aws:SecureTransport": "false"}, }, }, }, } enforcedTLSPolicy.Statement = append(enforcedTLSPolicy.Statement, policyInBucket.Statement...) policy, err := awshelper.MarshalPolicy(enforcedTLSPolicy) if err != nil { return errors.Errorf("error marshalling policy for bucket %s: %w", bucket, err) } _, err = client.s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ Bucket: aws.String(bucket), Policy: aws.String(string(policy)), }) if err != nil { return errors.Errorf("error putting policy for bucket %s: %w", bucket, err) } l.Debugf("Enabled enforced TLS access to bucket %s", bucket) return nil } func (client *Client) EnablePublicAccessBlockingForS3Bucket(ctx context.Context, l log.Logger, bucketName string) error { input := &s3.PutPublicAccessBlockInput{ Bucket: aws.String(bucketName), PublicAccessBlockConfiguration: &types.PublicAccessBlockConfiguration{ BlockPublicAcls: aws.Bool(true), IgnorePublicAcls: aws.Bool(true), BlockPublicPolicy: aws.Bool(true), RestrictPublicBuckets: aws.Bool(true), }, } _, err := client.s3Client.PutPublicAccessBlock(ctx, input) if err != nil { return errors.New(err) } l.Debugf("Enabled public access blocking for S3 bucket %s", bucketName) return nil } func (client *Client) EnableAccessLoggingForS3BucketWide(ctx context.Context, l log.Logger) error { if client.ExtendedRemoteStateConfigS3 == nil { return errors.Errorf("client configuration is nil - cannot enable access logging for S3 bucket") } cfg := client.ExtendedRemoteStateConfigS3 bucket := cfg.RemoteStateConfigS3.Bucket logsBucket := cfg.AccessLoggingBucketName logsBucketPrefix := cfg.AccessLoggingTargetPrefix if logsBucket == "" { return errors.Errorf("AccessLoggingBucketName is required for bucket-wide Access Logging on AWS S3 bucket %s", cfg.RemoteStateConfigS3.Bucket) } if !client.SkipAccessLoggingBucketACL { if err := client.configureBucketAccessLoggingACL(ctx, l, logsBucket); err != nil { return errors.Errorf("error configuring bucket access logging ACL on S3 bucket %s: %w", cfg.RemoteStateConfigS3.Bucket, err) } } loggingInput := client.CreateS3LoggingInput() l.Debugf("Putting bucket logging on S3 bucket %s with TargetBucket %s and TargetPrefix %s\n%v", bucket, logsBucket, logsBucketPrefix, loggingInput) if _, err := client.s3Client.PutBucketLogging(ctx, &loggingInput); err != nil { return errors.Errorf("error enabling bucket-wide Access Logging on AWS S3 bucket %s: %w", cfg.RemoteStateConfigS3.Bucket, err) } l.Debugf("Enabled bucket-wide Access Logging on AWS S3 bucket %s", bucket) return nil } func (client *Client) configureBucketAccessLoggingACL(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Granting WRITE and READ_ACP permissions to S3 Log Delivery (%s) for bucket %s. This is required for access logging.", s3LogDeliveryGranteeURI, bucketName) uri := "uri=" + s3LogDeliveryGranteeURI aclInput := s3.PutBucketAclInput{ Bucket: aws.String(bucketName), GrantWrite: aws.String(uri), GrantReadACP: aws.String(uri), } if _, err := client.s3Client.PutBucketAcl(ctx, &aclInput); err != nil { return errors.Errorf("error granting WRITE and READ_ACP permissions to S3 Log Delivery (%s) for bucket %s: %w", s3LogDeliveryGranteeURI, bucketName, err) } return client.waitUntilBucketHasAccessLoggingACL(ctx, l, bucketName) } func (client *Client) waitUntilBucketHasAccessLoggingACL(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Waiting for ACL bucket %s to have the updated ACL for access logging.", bucketName) maxRetries := 10 for range maxRetries { res, err := client.s3Client.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: aws.String(bucketName)}) if err != nil { return errors.Errorf("error getting ACL for bucket %s: %w", bucketName, err) } hasReadAcp := false hasWrite := false for _, grant := range res.Grants { if aws.ToString(grant.Grantee.URI) == s3LogDeliveryGranteeURI { if string(grant.Permission) == "READ_ACP" { hasReadAcp = true } if string(grant.Permission) == "WRITE" { hasWrite = true } } } if hasReadAcp && hasWrite { l.Debugf("Bucket %s now has the proper ACL permissions for access logging!", bucketName) return nil } l.Debugf("Bucket %s still does not have the ACL permissions for access logging. Will sleep for %v and check again.", bucketName, s3TimeBetweenRetries) time.Sleep(s3TimeBetweenRetries) } return errors.New(MaxRetriesWaitingForS3ACLExceeded(bucketName)) } // checkBucketAccess checks if the current user has the ability to access the S3 bucket keys. func (client *Client) checkBucketAccess(ctx context.Context, bucket, key string) error { _, err := client.s3Client.GetObject(ctx, &s3.GetObjectInput{Key: aws.String(key), Bucket: aws.String(bucket)}) if err == nil { return nil } var apiErr smithy.APIError if ok := errors.As(err, &apiErr); !ok { return errors.Errorf("error checking access to S3 bucket %s: %w", bucket, err) } return errors.Errorf("error checking access to S3 bucket %s: %w", bucket, err) } // DeleteS3BucketIfNecessary deletes the given S3 bucket with all its objects if it exists. func (client *Client) DeleteS3BucketIfNecessary(ctx context.Context, l log.Logger, bucketName string) error { if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, bucketName); err != nil || !exists { return err } description := fmt.Sprintf("Delete S3 bucket %s with retry", bucketName) return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { err := client.DeleteS3BucketWithAllObjects(ctx, l, bucketName) if err == nil { return nil } if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} }) } // DeleteS3BucketWithAllObjects deletes the given S3 bucket with all its objects. func (client *Client) DeleteS3BucketWithAllObjects(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Delete S3 bucket %s with all objects.", bucketName) if err := client.DeleteS3BucketObjects(ctx, l, bucketName); err != nil { return err } return client.DeleteS3Bucket(ctx, l, bucketName) } // DeleteS3BucketObject deletes S3 bucket object by the given key. func (client *Client) DeleteS3BucketObject(ctx context.Context, l log.Logger, bucketName, key string, versionID *string) error { objectInput := &s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(key), VersionId: versionID, } if _, err := client.s3Client.DeleteObject(ctx, objectInput); err != nil { return errors.Errorf("failed to delete object: %w", err) } return nil } // DeleteS3BucketV2Objects deletes S3 bucket object by the given key. func (client *Client) DeleteS3BucketV2Objects(ctx context.Context, l log.Logger, bucketName string) error { var v2Input = &s3.ListObjectsV2Input{Bucket: aws.String(bucketName)} for { select { case <-ctx.Done(): return ctx.Err() default: } res, err := client.s3Client.ListObjectsV2(ctx, v2Input) if err != nil { return errors.Errorf("failed to list objects: %w", err) } for _, item := range res.Contents { if err := client.DeleteS3BucketObject(ctx, l, bucketName, aws.ToString(item.Key), nil); err != nil { return err } } if !aws.ToBool(res.IsTruncated) { break } v2Input.ContinuationToken = res.ContinuationToken } return nil } // DeleteS3BucketVersionObjects deletes S3 bucket object versions by the given key. func (client *Client) DeleteS3BucketVersionObjects(ctx context.Context, l log.Logger, bucketName string, keys ...string) error { var versionsInput = &s3.ListObjectVersionsInput{Bucket: aws.String(bucketName)} for { select { case <-ctx.Done(): return ctx.Err() default: } res, err := client.s3Client.ListObjectVersions(ctx, versionsInput) if err != nil { return errors.Errorf("failed to list version objects: %w", err) } for _, item := range res.DeleteMarkers { if len(keys) != 0 && !slices.Contains(keys, aws.ToString(item.Key)) { continue } if err := client.DeleteS3BucketObject(ctx, l, bucketName, aws.ToString(item.Key), item.VersionId); err != nil { return err } } for i := range res.Versions { item := &res.Versions[i] if len(keys) != 0 && !slices.Contains(keys, aws.ToString(item.Key)) { continue } if err := client.DeleteS3BucketObject(ctx, l, bucketName, aws.ToString(item.Key), item.VersionId); err != nil { return err } } if !aws.ToBool(res.IsTruncated) { break } versionsInput.VersionIdMarker = res.NextVersionIdMarker versionsInput.KeyMarker = res.NextKeyMarker } return nil } // DeleteS3BucketObjects deletes the S3 bucket contents. func (client *Client) DeleteS3BucketObjects(ctx context.Context, l log.Logger, bucketName string) error { if err := client.DeleteS3BucketV2Objects(ctx, l, bucketName); err != nil { return err } return client.DeleteS3BucketVersionObjects(ctx, l, bucketName) } // DeleteS3Bucket deletes the S3 bucket specified in the given config. func (client *Client) DeleteS3Bucket(ctx context.Context, l log.Logger, bucketName string) error { var ( cfg = &client.ExtendedRemoteStateConfigS3.RemoteStateConfigS3 key = cfg.Key bucketInput = &s3.DeleteBucketInput{Bucket: aws.String(bucketName)} ) l.Debugf("Deleting S3 bucket %s", bucketName) if _, err := client.s3Client.DeleteBucket(ctx, bucketInput); err != nil { if err := client.checkBucketAccess(ctx, bucketName, key); err != nil { return err } return errors.New(err) } l.Debugf("Deleted S3 bucket %s", bucketName) return client.WaitUntilS3BucketDeleted(ctx, l, bucketName) } // WaitUntilS3BucketDeleted waits until the given S3 bucket is deleted. func (client *Client) WaitUntilS3BucketDeleted(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Waiting for bucket %s to be deleted", bucketName) for retries := range maxRetriesWaitingForS3Bucket { select { case <-ctx.Done(): return ctx.Err() default: } if exists, err := client.DoesS3BucketExist(ctx, bucketName); err != nil { return err } else if !exists { l.Debugf("S3 bucket %s deleted.", bucketName) return nil } else if retries < maxRetriesWaitingForS3Bucket-1 { l.Debugf("S3 bucket %s has not been deleted yet. Sleeping for %s and will check again.", bucketName, sleepBetweenRetriesWaitingForS3Bucket) time.Sleep(sleepBetweenRetriesWaitingForS3Bucket) } } return errors.New(MaxRetriesWaitingForS3BucketExceeded(bucketName)) } // DeleteS3ObjectIfNecessary deletes the S3 object by the specified key if it exists. func (client *Client) DeleteS3ObjectIfNecessary(ctx context.Context, l log.Logger, bucketName, key string) error { if exists, err := client.DoesS3BucketExistWithLogging(ctx, l, bucketName); err != nil || !exists { return err } if exists, err := client.DoesS3ObjectExist(ctx, bucketName, key); err != nil || !exists { return err } description := fmt.Sprintf("Delete S3 object %s in bucket %s with retry", key, bucketName) return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { if err := client.DeleteS3BucketObject(ctx, l, bucketName, key, nil); err != nil { if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} } return nil }) } // DoesS3ObjectExist returns true if the specified S3 object exists otherwise false. func (client *Client) DoesS3ObjectExist(ctx context.Context, bucketName, key string) (bool, error) { input := &s3.HeadObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(key), } if _, err := client.s3Client.HeadObject(ctx, input); err != nil { var apiErr smithy.APIError if ok := errors.As(err, &apiErr); ok { if apiErr.ErrorCode() == "NotFound" { // s3.ErrCodeNoSuchKey does not work, aws is missing this error code so we hardwire a string return false, nil } } return false, err } return true, nil } func (client *Client) DoesS3ObjectExistWithLogging(ctx context.Context, l log.Logger, bucketName, key string) (bool, error) { if client.s3Client == nil { return false, errors.Errorf("S3 client is nil - cannot check if S3 bucket %s exists", bucketName) } l.Debugf("Checking if bucket %s exists", bucketName) if exists, err := client.DoesS3ObjectExist(ctx, bucketName, key); err != nil || exists { return exists, err } l.Debugf("Remote state S3 bucket %s object %s does not exist or you don't have permissions to access it.", bucketName, key) return false, nil } // CreateLockTableIfNecessary creates the lock table in DynamoDB if it doesn't already exist. func (client *Client) CreateLockTableIfNecessary(ctx context.Context, l log.Logger, tableName string, tags map[string]string) error { tableExists, err := client.DoesLockTableExistAndIsActive(ctx, tableName) if err != nil { return err } if !tableExists { l.Debugf("Lock table %s does not exist in DynamoDB. Will need to create it just this first time.", tableName) return client.CreateLockTable(ctx, l, tableName, tags) } return nil } // DeleteTableIfNecessary deletes the given table if it exists. func (client *Client) DeleteTableIfNecessary(ctx context.Context, l log.Logger, tableName string) error { if exists, err := client.DoesLockTableExist(ctx, tableName); err != nil || !exists { return err } return client.DeleteTable(ctx, l, tableName) } // DoesLockTableExistAndIsActive returns true if the specified DynamoDB table exists and is active otherwise false. func (client *Client) DoesLockTableExistAndIsActive(ctx context.Context, tableName string) (bool, error) { input := &dynamodb.DescribeTableInput{ TableName: aws.String(tableName), } res, err := client.dynamoClient.DescribeTable(ctx, input) if err != nil { if isAWSResourceNotFoundError(err) { // Table doesn't exist, so it's not active return false, nil } return false, err } return res.Table.TableStatus == dynamodbtypes.TableStatusActive, nil } // DoesLockTableExist returns true if the lock table exists. func (client *Client) DoesLockTableExist(ctx context.Context, tableName string) (bool, error) { input := &dynamodb.DescribeTableInput{ TableName: aws.String(tableName), } _, err := client.dynamoClient.DescribeTable(ctx, input) if err != nil { if isAWSResourceNotFoundError(err) { return false, nil } else { return false, errors.New(err) } } return true, nil } // LockTableCheckSSEncryptionIsOn returns true if the lock table's SSEncryption is turned on func (client *Client) LockTableCheckSSEncryptionIsOn(ctx context.Context, tableName string) (bool, error) { input := &dynamodb.DescribeTableInput{ TableName: aws.String(tableName), } output, err := client.dynamoClient.DescribeTable(ctx, input) if err != nil { if isAWSResourceNotFoundError(err) { // Table doesn't exist, so encryption is not enabled return false, nil } return false, errors.New(err) } return output.Table.SSEDescription != nil && string(output.Table.SSEDescription.Status) == string(dynamodbtypes.SSEStatusEnabled), nil } // CreateLockTable creates a lock table in DynamoDB and wait until it is in "active" state. // If the table already exists, merely wait until it is in "active" state. func (client *Client) CreateLockTable(ctx context.Context, l log.Logger, tableName string, tags map[string]string) error { tableCreateDeleteSemaphore.Acquire() defer tableCreateDeleteSemaphore.Release() l.Debugf("Creating table %s in DynamoDB", tableName) attributeDefinitions := []dynamodbtypes.AttributeDefinition{ {AttributeName: aws.String(AttrLockID), AttributeType: dynamodbtypes.ScalarAttributeTypeS}, } keySchema := []dynamodbtypes.KeySchemaElement{ {AttributeName: aws.String(AttrLockID), KeyType: dynamodbtypes.KeyTypeHash}, } input := &dynamodb.CreateTableInput{ TableName: aws.String(tableName), BillingMode: dynamodbtypes.BillingMode(DynamodbPayPerRequestBillingMode), AttributeDefinitions: attributeDefinitions, KeySchema: keySchema, } createTableOutput, err := client.dynamoClient.CreateTable(ctx, input) if err != nil { if isTableAlreadyBeingCreatedOrUpdatedError(err) { l.Debugf("Looks like someone created table %s at the same time. Will wait for it to be in active state.", tableName) } else { return errors.New(err) } } err = client.waitForTableToBeActive(ctx, l, tableName, MaxRetriesWaitingForTableToBeActive, SleepBetweenTableStatusChecks) if err != nil { return err } if createTableOutput != nil && createTableOutput.TableDescription != nil && createTableOutput.TableDescription.TableArn != nil { // Do not tag in case somebody else had created the table err = client.tagTableIfTagsGiven(ctx, l, tags, createTableOutput.TableDescription.TableArn) if err != nil { return errors.New(err) } } return nil } func (client *Client) tagTableIfTagsGiven(ctx context.Context, l log.Logger, tags map[string]string, tableArn *string) error { if len(tags) == 0 { l.Debugf("No tags for lock table given.") return nil } // we were able to create the table successfully, now add tags l.Debugf("Adding tags to lock table: %s", tags) var tagsConverted = make([]dynamodbtypes.Tag, 0, len(tags)) for k, v := range tags { tagsConverted = append(tagsConverted, dynamodbtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) } var input = dynamodb.TagResourceInput{ ResourceArn: tableArn, Tags: tagsConverted} _, err := client.dynamoClient.TagResource(ctx, &input) return err } // DeleteTable deletes the given table in DynamoDB. func (client *Client) DeleteTable(ctx context.Context, l log.Logger, tableName string) error { tableCreateDeleteSemaphore.Acquire() defer tableCreateDeleteSemaphore.Release() l.Debugf("Deleting DynamoD table %s", tableName) input := &dynamodb.DeleteTableInput{TableName: aws.String(tableName)} // It is not always able to delete a table the first attempt, as we can get a 400 from tags still being updated // while the table is being deleted. // // We retry to handle this race condition. const ( maxRetries = 5 delay = 2 * time.Second ) for i := range maxRetries { _, err := client.dynamoClient.DeleteTable(ctx, input) if err == nil { return nil } if isTableAlreadyBeingCreatedOrUpdatedError(err) { if i < maxRetries-1 { l.Debugf("Table %s is still being updated (likely tags). Will retry deletion after %s (attempt %d/%d)", tableName, delay, i+1, maxRetries) time.Sleep(delay) continue } } return err } return errors. Errorf("Failed to delete table %s after %d attempts", tableName, maxRetries) } // Return true if the given error is the error message returned by AWS when the resource already exists and is being // updated by someone else func isTableAlreadyBeingCreatedOrUpdatedError(err error) bool { var apiErr smithy.APIError ok := errors.As(err, &apiErr) return ok && apiErr.ErrorCode() == "ResourceInUseException" } // Wait for the given DynamoDB table to be in the "active" state. If it's not in "active" state, sleep for the // specified amount of time, and try again, up to a maximum of maxRetries retries. func (client *Client) waitForTableToBeActive(ctx context.Context, l log.Logger, tableName string, maxRetries int, sleepBetweenRetries time.Duration) error { return client.WaitForTableToBeActiveWithRandomSleep(ctx, l, tableName, maxRetries, sleepBetweenRetries, sleepBetweenRetries) } // WaitForTableToBeActiveWithRandomSleep waits for the given table as described above, // but sleeps a random amount of time greater than sleepBetweenRetriesMin // and less than sleepBetweenRetriesMax between tries. This is to avoid an AWS issue where all waiting requests fire at // the same time, which continually triggered AWS's "subscriber limit exceeded" API error. func (client *Client) WaitForTableToBeActiveWithRandomSleep(ctx context.Context, l log.Logger, tableName string, maxRetries int, sleepBetweenRetriesMin time.Duration, sleepBetweenRetriesMax time.Duration) error { for range maxRetries { tableReady, err := client.DoesLockTableExistAndIsActive(ctx, tableName) if err != nil { return err } if tableReady { l.Debugf("Success! Table %s is now in active state.", tableName) return nil } sleepBetweenRetries := util.GetRandomTime(sleepBetweenRetriesMin, sleepBetweenRetriesMax) l.Debugf("Table %s is not yet in active state. Will check again after %s.", tableName, sleepBetweenRetries) time.Sleep(sleepBetweenRetries) } return errors.New(TableActiveRetriesExceeded{TableName: tableName, Retries: maxRetries}) } // UpdateLockTableSetSSEncryptionOnIfNecessary encrypts the TFState Lock table - If Necessary func (client *Client) UpdateLockTableSetSSEncryptionOnIfNecessary(ctx context.Context, l log.Logger, tableName string) error { tableSSEncrypted, err := client.LockTableCheckSSEncryptionIsOn(ctx, tableName) if err != nil { return errors.New(err) } if tableSSEncrypted { l.Debugf("Table %s already has encryption enabled", tableName) return nil } tableCreateDeleteSemaphore.Acquire() defer tableCreateDeleteSemaphore.Release() l.Debugf("Enabling server-side encryption on table %s in AWS DynamoDB", tableName) input := &dynamodb.UpdateTableInput{ SSESpecification: &dynamodbtypes.SSESpecification{ Enabled: aws.Bool(true), SSEType: dynamodbtypes.SSETypeKms, }, TableName: aws.String(tableName), } if _, err := client.dynamoClient.UpdateTable(ctx, input); err != nil { if isTableAlreadyBeingCreatedOrUpdatedError(err) { l.Debugf("Looks like someone is already updating table %s at the same time. Will wait for that update to complete.", tableName) } else { return errors.New(err) } } if err := client.waitForEncryptionToBeEnabled(ctx, l, tableName); err != nil { return errors.New(err) } return client.waitForTableToBeActive(ctx, l, tableName, MaxRetriesWaitingForTableToBeActive, SleepBetweenTableStatusChecks) } // Wait until encryption is enabled for the given table func (client *Client) waitForEncryptionToBeEnabled(ctx context.Context, l log.Logger, tableName string) error { l.Debugf("Waiting for encryption to be enabled on table %s", tableName) for range maxRetriesWaitingForEncryption { tableSSEncrypted, err := client.LockTableCheckSSEncryptionIsOn(ctx, tableName) if err != nil { return errors.New(err) } if tableSSEncrypted { l.Debugf("Encryption is now enabled for table %s!", tableName) return nil } l.Debugf("Encryption is still not enabled for table %s. Will sleep for %v and try again.", tableName, sleepBetweenRetriesWaitingForEncryption) time.Sleep(sleepBetweenRetriesWaitingForEncryption) } return errors.New(TableEncryptedRetriesExceeded{TableName: tableName, Retries: maxRetriesWaitingForEncryption}) } // DeleteTableItemIfNecessary deletes the given DynamoDB table key, if the table exists. func (client *Client) DeleteTableItemIfNecessary(ctx context.Context, l log.Logger, tableName, key string) error { if exists, err := client.DoesTableItemExist(ctx, tableName, key); err != nil || !exists { return err } description := fmt.Sprintf("Delete DynamoDB table %s item %s", tableName, key) return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { if err := client.DeleteTableItem(ctx, l, tableName, key); err != nil { if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} } return nil }) } // DeleteTableItem deletes the given DynamoDB table key. func (client *Client) DeleteTableItem(ctx context.Context, l log.Logger, tableName, key string) error { l.Debugf("Deleting DynamoDB table %s item %s", tableName, key) input := &dynamodb.DeleteItemInput{ TableName: aws.String(tableName), Key: map[string]dynamodbtypes.AttributeValue{ AttrLockID: &dynamodbtypes.AttributeValueMemberS{ Value: key, }, }, } if _, err := client.dynamoClient.DeleteItem(ctx, input); err != nil { return errors.Errorf("failed to remove item by key %s of table %s: %w", key, tableName, err) } return nil } // DoesTableItemExist returns true if the given DynamoDB table and its key exist otherwise false. func (client *Client) DoesTableItemExist(ctx context.Context, tableName, key string) (bool, error) { if exists, err := client.DoesLockTableExist(ctx, tableName); err != nil || !exists { return false, err } input := &dynamodb.GetItemInput{ TableName: aws.String(tableName), Key: map[string]dynamodbtypes.AttributeValue{ AttrLockID: &dynamodbtypes.AttributeValueMemberS{ Value: key, }, }, } res, err := client.dynamoClient.GetItem(ctx, input) if err != nil { return false, errors.Errorf("failed to get item by key %s of table %s: %w", key, tableName, err) } exists := len(res.Item) != 0 return exists, nil } // MoveS3Object copies the S3 object at the specified srcKey to dstKey and then removes srcKey. func (client *Client) MoveS3Object(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { if err := client.CopyS3BucketObject(ctx, l, srcBucketName, srcKey, dstBucketName, dstKey); err != nil { return err } return client.DeleteS3BucketObject(ctx, l, srcBucketName, srcKey, nil) } // CreateTableItem creates a new table item `key` in DynamoDB. func (client *Client) CreateTableItem(ctx context.Context, l log.Logger, tableName, key string) error { l.Debugf("Creating DynamoDB %s item %s", tableName, key) input := &dynamodb.PutItemInput{ TableName: aws.String(tableName), Item: map[string]dynamodbtypes.AttributeValue{ AttrLockID: &dynamodbtypes.AttributeValueMemberS{Value: key}, }, } if _, err := client.dynamoClient.PutItem(ctx, input); err != nil { return errors.Errorf("failed to create table item: %w", err) } return nil } // EnableVersioningForS3Bucket enables versioning for the S3 bucket specified in the given config. func (client *Client) EnableVersioningForS3Bucket(ctx context.Context, l log.Logger, bucketName string) error { l.Debugf("Enabling versioning for S3 bucket %s", bucketName) input := s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ Status: types.BucketVersioningStatusEnabled, }, } _, err := client.s3Client.PutBucketVersioning(ctx, &input) if err != nil { return errors.New(err) } l.Debugf("Enabled versioning for S3 bucket %s", bucketName) return nil } // EnableSSEForS3BucketWide enables server-side encryption for the S3 bucket specified in the given config. func (client *Client) EnableSSEForS3BucketWide(ctx context.Context, l log.Logger, bucketName string, algorithm string) error { l.Debugf("Enabling server-side encryption for S3 bucket %s", bucketName) accountID, err := awshelper.GetAWSAccountID(ctx, &client.awsConfig) if err != nil { return errors.Errorf("error getting AWS account ID %s for bucket %s: %w", accountID, bucketName, err) } partition, err := awshelper.GetAWSPartition(ctx, &client.awsConfig) if err != nil { return errors.Errorf("error getting AWS partition %s for bucket %s: %w", partition, bucketName, err) } input := &s3.PutBucketEncryptionInput{ Bucket: aws.String(bucketName), ServerSideEncryptionConfiguration: &types.ServerSideEncryptionConfiguration{ Rules: []types.ServerSideEncryptionRule{ { ApplyServerSideEncryptionByDefault: &types.ServerSideEncryptionByDefault{ SSEAlgorithm: types.ServerSideEncryption(algorithm), }, BucketKeyEnabled: aws.Bool(true), }, }, }, } // If using KMS encryption and a specific KMS key ID is configured, set it if algorithm == string(types.ServerSideEncryptionAwsKms) && client.BucketSSEKMSKeyID != "" { input.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.KMSMasterKeyID = aws.String(client.BucketSSEKMSKeyID) } _, err = client.s3Client.PutBucketEncryption(ctx, input) if err != nil { return errors.New(err) } l.Debugf("Enabled server-side encryption for S3 bucket %s", bucketName) return nil } // checkIfSSEForS3MatchesConfig checks if the SSE configuration matches the expected configuration. func (client *Client) checkIfSSEForS3MatchesConfig(ctx context.Context, bucketName string) (bool, error) { output, err := client.s3Client.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{ Bucket: aws.String(bucketName), }) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "ServerSideEncryptionConfigurationNotFoundError" { return false, nil } return false, errors.New(err) } expectedAlgorithm := client.FetchEncryptionAlgorithm() for _, rule := range output.ServerSideEncryptionConfiguration.Rules { if rule.ApplyServerSideEncryptionByDefault == nil { continue } if string(rule.ApplyServerSideEncryptionByDefault.SSEAlgorithm) != expectedAlgorithm { continue } if expectedAlgorithm != string(types.ServerSideEncryptionAwsKms) { return true, nil } if client.BucketSSEKMSKeyID == "" { return true, nil } if rule.ApplyServerSideEncryptionByDefault.KMSMasterKeyID == nil { return false, nil } if aws.ToString(rule.ApplyServerSideEncryptionByDefault.KMSMasterKeyID) != client.BucketSSEKMSKeyID { return false, nil } return true, nil } return false, nil } // checkIfBucketPolicyStatementExists checks if a specific policy statement exists in the bucket policy func (client *Client) checkIfBucketPolicyStatementExists(ctx context.Context, bucketName, statementSid string) (bool, error) { policyOutput, err := client.s3Client.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{ Bucket: aws.String(bucketName), }) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchBucketPolicy" { return false, nil } return false, errors.New(err) } if policyOutput.Policy == nil { return false, nil } policyInBucket, err := awshelper.UnmarshalPolicy(*policyOutput.Policy) if err != nil { return false, errors.New(err) } // Safety check to avoid nil pointer dereference if policyInBucket.Statement == nil { return false, nil } for _, statement := range policyInBucket.Statement { if statement.Sid == statementSid { return true, nil } } return false, nil } // checkIfBucketRootAccess checks if the root access policy is enabled for the bucket. func (client *Client) checkIfBucketRootAccess(ctx context.Context, l log.Logger, bucketName string) (bool, error) { l.Debugf("Checking if bucket %s has root access", bucketName) return client.checkIfBucketPolicyStatementExists(ctx, bucketName, SidRootPolicy) } // checkIfBucketEnforcedTLS checks if the enforced TLS policy is enabled for the bucket. func (client *Client) checkIfBucketEnforcedTLS(ctx context.Context, l log.Logger, bucketName string) (bool, error) { l.Debugf("Checking if bucket %s has enforced TLS", bucketName) return client.checkIfBucketPolicyStatementExists(ctx, bucketName, SidEnforcedTLSPolicy) } // DoesS3BucketExist checks if the S3 bucket exists and is accessible. func (client *Client) DoesS3BucketExist(ctx context.Context, bucketName string) (bool, error) { input := &s3.HeadBucketInput{Bucket: aws.String(bucketName)} _, err := client.s3Client.HeadBucket(ctx, input) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" { return false, nil } return false, errors.New(err) } return true, nil } // DoesS3BucketExistWithLogging checks if the S3 bucket exists and logs if not. func (client *Client) DoesS3BucketExistWithLogging(ctx context.Context, l log.Logger, bucketName string) (bool, error) { if client.s3Client == nil { return false, errors.Errorf("S3 client is nil - cannot check if S3 bucket %s exists", bucketName) } l.Debugf("Checking if bucket %s exists", bucketName) exists, err := client.DoesS3BucketExist(ctx, bucketName) if err != nil || !exists { l.Debugf("Remote state S3 bucket %s does not exist or you don't have permissions to access it.", bucketName) } return exists, err } // checkS3AccessLoggingConfiguration checks if access logging is enabled for the S3 bucket. func (client *Client) checkS3AccessLoggingConfiguration(ctx context.Context, bucketName string) (bool, error) { input := &s3.GetBucketLoggingInput{Bucket: aws.String(bucketName)} output, err := client.s3Client.GetBucketLogging(ctx, input) if err != nil { return false, errors.New(err) } return output.LoggingEnabled != nil, nil } // checkIfS3PublicAccessBlockingEnabled checks if public access blocking is enabled for the S3 bucket. func (client *Client) checkIfS3PublicAccessBlockingEnabled(ctx context.Context, bucketName string) (bool, error) { output, err := client.s3Client.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{ Bucket: aws.String(bucketName), }) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchPublicAccessBlockConfiguration" { return false, nil } return false, errors.New(err) } return output.PublicAccessBlockConfiguration != nil && aws.ToBool(output.PublicAccessBlockConfiguration.BlockPublicAcls) && aws.ToBool(output.PublicAccessBlockConfiguration.IgnorePublicAcls) && aws.ToBool(output.PublicAccessBlockConfiguration.BlockPublicPolicy) && aws.ToBool(output.PublicAccessBlockConfiguration.RestrictPublicBuckets), nil } // CopyS3BucketObject copies the S3 object at the specified srcBucketName and srcKey to dstBucketName and dstKey. func (client *Client) CopyS3BucketObject(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { l.Debugf("Copying S3 bucket object from %s to %s", path.Join(srcBucketName, srcKey), path.Join(dstBucketName, dstKey)) input := &s3.CopyObjectInput{ Bucket: aws.String(dstBucketName), Key: aws.String(dstKey), CopySource: aws.String(path.Join(srcBucketName, srcKey)), } if _, err := client.s3Client.CopyObject(ctx, input); err != nil { return errors.Errorf("failed to copy object: %w", err) } return nil } // MoveS3ObjectIfNecessary moves the S3 object at the specified srcBucketName and srcKey to dstBucketName and dstKey, only if it exists and does not already exist at the destination. func (client *Client) MoveS3ObjectIfNecessary(ctx context.Context, l log.Logger, srcBucketName, srcKey, dstBucketName, dstKey string) error { exists, err := client.DoesS3ObjectExistWithLogging(ctx, l, srcBucketName, srcKey) if err != nil || !exists { return err } exists, err = client.DoesS3ObjectExist(ctx, dstBucketName, dstKey) if err != nil { return err } else if exists { return errors.Errorf("destination S3 bucket %s object %s already exists", dstBucketName, dstKey) } description := fmt.Sprintf("Move S3 bucket object from %s to %s", path.Join(srcBucketName, srcKey), path.Join(dstBucketName, dstKey)) return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { if err := client.MoveS3Object(ctx, l, srcBucketName, srcKey, dstBucketName, dstKey); err != nil { if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} } return nil }) } // CreateTableItemIfNecessary creates the DynamoDB table item with the specified key, only if it does not already exist. func (client *Client) CreateTableItemIfNecessary(ctx context.Context, l log.Logger, tableName, key string) error { exists, err := client.DoesTableItemExist(ctx, tableName, key) if err != nil { return err } else if exists { return errors.Errorf("DynamoDB table %s item %s already exists", tableName, key) } description := fmt.Sprintf("Create DynamoDB table %s item %s", tableName, key) return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, l, log.DebugLevel, func(ctx context.Context) error { if err := client.CreateTableItem(ctx, l, tableName, key); err != nil { if isBucketErrorRetriable(l, err) { return err } // return FatalError so that retry loop will not continue return util.FatalError{Underlying: err} } return nil }) } // GetDynamoDBClient returns the DynamoDB client for testing purposes. func (client *Client) GetDynamoDBClient() *dynamodb.Client { return client.dynamoClient } // GetS3Client returns the S3 client for testing purposes. func (client *Client) GetS3Client() *s3.Client { return client.s3Client } // isAWSResourceNotFoundError checks if an error indicates that an AWS resource was not found func isAWSResourceNotFoundError(err error) bool { var apiErr smithy.APIError return errors.As(err, &apiErr) && apiErr.ErrorCode() == "ResourceNotFoundException" } // handleS3TaggingMethodNotAllowed handles MethodNotAllowed errors for S3 bucket tagging operations // Returns true if the error was handled (caller should return nil), false otherwise func handleS3TaggingMethodNotAllowed(err error, l log.Logger, bucketName string) bool { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "MethodNotAllowed" { l.Warnf("S3 bucket tagging is not supported for bucket %s - skipping tagging (this is normal for some AWS configurations)", bucketName) return true } return false } ================================================ FILE: internal/remotestate/backend/s3/client_test.go ================================================ //go:build aws package s3_test import ( "reflect" "strconv" "strings" "sync" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" dynamodbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // defaultTestRegion is for simplicity, do all testing in the us-east-1 region const defaultTestRegion = "us-east-1" // CreateS3ClientForTest creates a DynamoDB client we can use at test time. If there are any errors creating the client, fail the test. func CreateS3ClientForTest(t *testing.T) *s3backend.Client { t.Helper() mockOptions := &backend.Options{} extS3Cfg := &s3backend.ExtendedRemoteStateConfigS3{ RemoteStateConfigS3: s3backend.RemoteStateConfigS3{ Region: defaultTestRegion, }, } l := logger.CreateLogger() client, err := s3backend.NewClient(t.Context(), l, extS3Cfg, mockOptions) require.NoError(t, err, "Error creating S3 client") return client } func TestAwsCreateLockTableIfNecessaryTableDoesntAlreadyExist(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) WithLockTable(t, client, func(tableName string, client *s3backend.Client) { AssertCanWriteToTable(t, tableName, client) }) } func TestAwsCreateLockTableConcurrency(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) tableName := UniqueTableNameForTest() defer CleanupTableForTest(t, tableName, client) // Use a WaitGroup to ensure the test doesn't exit before all goroutines finish. var waitGroup sync.WaitGroup l := logger.CreateLogger() // Launch a bunch of goroutines who will all try to create the same table at more or less the same time. // DynamoDB will, of course, only allow a single table to be created, but we still need to make sure none of // the goroutines report an error. for i := 0; i < 20; i++ { waitGroup.Add(1) go func() { defer waitGroup.Done() err := client.CreateLockTableIfNecessary(t.Context(), l, tableName, nil) assert.NoError(t, err, "Unexpected error: %v", err) }() } waitGroup.Wait() } func TestAwsWaitForTableToBeActiveTableDoesNotExist(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) tableName := "terragrunt-table-does-not-exist" retries := 5 l := logger.CreateLogger() err := client.WaitForTableToBeActiveWithRandomSleep(t.Context(), l, tableName, retries, 1*time.Millisecond, 500*time.Millisecond) errorMatchs := errors.IsError(err, s3backend.TableActiveRetriesExceeded{TableName: tableName, Retries: retries}) assert.True(t, errorMatchs, "Unexpected error of type %s: %s", reflect.TypeOf(err), err) } func TestAwsCreateLockTableIfNecessaryTableAlreadyExists(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) // Create the table the first time WithLockTable(t, client, func(tableName string, client *s3backend.Client) { AssertCanWriteToTable(t, tableName, client) l := logger.CreateLogger() // Try to create the table the second time and make sure you get no errors err := client.CreateLockTableIfNecessary(t.Context(), l, tableName, nil) require.NoError(t, err, "Unexpected error: %v", err) }) } func TestAwsTableTagging(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) tags := map[string]string{"team": "team A"} // Create the table the first time WithLockTableTagged(t, tags, client, func(tableName string, client *s3backend.Client) { AssertCanWriteToTable(t, tableName, client) assertTags(t, tags, tableName, client) l := logger.CreateLogger() // Try to create the table the second time and make sure you get no errors err := client.CreateLockTableIfNecessary(t.Context(), l, tableName, nil) require.NoError(t, err, "Unexpected error: %v", err) }) } func assertTags(t *testing.T, expectedTags map[string]string, tableName string, client *s3backend.Client) { t.Helper() // Access the dynamodb client directly from the S3 client dynamoClient := client.GetDynamoDBClient() var description, err = dynamoClient.DescribeTable(t.Context(), &dynamodb.DescribeTableInput{TableName: aws.String(tableName)}) if err != nil { require.NoError(t, err, "Unexpected error: %v", err) } var tags = listTagsOfResourceWithRetry(t, client, description.Table.TableArn) var actualTags = make(map[string]string) for _, element := range tags.Tags { actualTags[*element.Key] = *element.Value } assert.Equal(t, expectedTags, actualTags, "Did not find expected tags on dynamo table.") } func listTagsOfResourceWithRetry(t *testing.T, client *s3backend.Client, resourceArn *string) *dynamodb.ListTagsOfResourceOutput { t.Helper() const ( delay = 1 * time.Second retries = 5 ) // Access the dynamodb client directly from the S3 client dynamoClient := client.GetDynamoDBClient() for range retries { var tags, err = dynamoClient.ListTagsOfResource(t.Context(), &dynamodb.ListTagsOfResourceInput{ResourceArn: resourceArn}) if err != nil { require.NoError(t, err, "Unexpected error: %v", err) } if len(tags.Tags) > 0 { return tags } time.Sleep(delay) } require.Failf(t, "Could not list tags of resource after %s retries.", strconv.Itoa(retries)) return nil } func UniqueTableNameForTest() string { return "terragrunt_test_" + util.UniqueID() } func CleanupTableForTest(t *testing.T, tableName string, client *s3backend.Client) { t.Helper() l := logger.CreateLogger() err := client.DeleteTable(t.Context(), l, tableName) require.NoError(t, err, "Unexpected error: %v", err) } func AssertCanWriteToTable(t *testing.T, tableName string, client *s3backend.Client) { t.Helper() item := CreateKeyFromItemID(util.UniqueID()) // Access the dynamodb client directly from the S3 client dynamoClient := client.GetDynamoDBClient() _, err := dynamoClient.PutItem(t.Context(), &dynamodb.PutItemInput{ TableName: aws.String(tableName), Item: item, }) require.NoError(t, err, "Unexpected error: %v", err) } func WithLockTable(t *testing.T, client *s3backend.Client, action func(tableName string, client *s3backend.Client)) { t.Helper() WithLockTableTagged(t, nil, client, action) } func WithLockTableTagged(t *testing.T, tags map[string]string, client *s3backend.Client, action func(tableName string, client *s3backend.Client)) { t.Helper() tableName := UniqueTableNameForTest() defer CleanupTableForTest(t, tableName, client) l := logger.CreateLogger() err := client.CreateLockTableIfNecessary(t.Context(), l, tableName, tags) require.NoError(t, err, "Unexpected error: %v", err) action(tableName, client) } func CreateKeyFromItemID(itemID string) map[string]dynamodbtypes.AttributeValue { return map[string]dynamodbtypes.AttributeValue{ "LockID": &dynamodbtypes.AttributeValueMemberS{Value: itemID}, } } // TestAwsCreateS3BucketWithTagsAtCreation verifies that tags passed via // CreateS3BucketOpts are applied at bucket creation time (via // CreateBucketConfiguration.Tags), without relying on a subsequent // PutBucketTagging call. func TestAwsCreateS3BucketWithTagsAtCreation(t *testing.T) { t.Parallel() client := CreateS3ClientForTest(t) bucketName := "terragrunt-test-" + strings.ToLower(util.UniqueID()) l := logger.CreateLogger() expectedTags := map[string]string{ "team": "platform", "env": "test", } // Create bucket with tags supplied only at creation time. err := client.CreateS3Bucket(t.Context(), l, bucketName, s3backend.CreateS3BucketOpts{Tags: expectedTags}) require.NoError(t, err) defer func() { require.NoError(t, client.DeleteS3BucketWithAllObjects(t.Context(), l, bucketName)) }() err = client.WaitUntilS3BucketExists(t.Context(), l, bucketName) require.NoError(t, err) // Verify tags are present — no PutBucketTagging was called, so these // must have come from CreateBucketConfiguration.Tags at creation time. s3Client := client.GetS3Client() tagsOut, err := s3Client.GetBucketTagging(t.Context(), &s3.GetBucketTaggingInput{ Bucket: aws.String(bucketName), }) require.NoError(t, err) actualTags := make(map[string]string) for _, tag := range tagsOut.TagSet { actualTags[*tag.Key] = *tag.Value } assert.Equal(t, expectedTags, actualTags, "Tags should be present from creation-time CreateBucketConfiguration.Tags") } ================================================ FILE: internal/remotestate/backend/s3/config.go ================================================ package s3 import ( "maps" "reflect" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/hclhelper" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mitchellh/mapstructure" ) const ( configLockTableKey = "lock_table" configDynamoDBTableKey = "dynamodb_table" configAssumeRoleKey = "assume_role" configAssumeRoleWithWebIdentityKey = "assume_role_with_web_identity" configAccessloggingTargetPrefixKey = "accesslogging_target_prefix" DefaultS3BucketAccessLoggingTargetPrefix = "TFStateLogs/" lockTableDeprecationMessage = "Remote state configuration 'lock_table' attribute is deprecated; use 'dynamodb_table' instead." ) type Config map[string]any func (cfg Config) FilterOutTerragruntKeys() Config { var filtered = make(Config) for key, val := range cfg { if slices.Contains(terragruntOnlyConfigs, key) { continue } filtered[key] = val } return filtered } func (cfg Config) GetTFInitArgs() Config { var filtered = make(Config) for key, val := range cfg.FilterOutTerragruntKeys() { // Remove the deprecated "lock_table" attribute so that it // will not be passed either when generating a backend block // or as a command-line argument. if key == configLockTableKey { filtered[configDynamoDBTableKey] = val continue } if key == configAssumeRoleKey { if mapVal, ok := val.(map[string]any); ok { filtered[key] = hclhelper.WrapMapToSingleLineHcl(mapVal) continue } } if key == configAssumeRoleWithWebIdentityKey { if mapVal, ok := val.(map[string]any); ok { filtered[key] = hclhelper.WrapMapToSingleLineHcl(mapVal) continue } } filtered[key] = val } // Normalize string boolean values to native Go bools using reflection // on the S3 config structs. HCL ternary type unification can convert // bools to strings, which causes generated backend blocks to contain // "true"/"false" string literals instead of true/false boolean literals. return Config(backend.NormalizeBoolValues(backend.Config(filtered), &ExtendedRemoteStateConfigS3{})) } func (cfg Config) Normalize(logger log.Logger) Config { var normalized = make(Config) maps.Copy(normalized, cfg) // Nowadays it only makes sense to set the "dynamodb_table" attribute as it has // been supported in Terraform since the release of version 0.10. The deprecated // "lock_table" attribute is either set to NULL in the state file or missing // from it altogether. Display a deprecation warning when the "lock_table" // attribute is being used. if util.KindOf(normalized[configLockTableKey]) == reflect.String && normalized[configLockTableKey] != "" { logger.Warnf("%s\n", lockTableDeprecationMessage) normalized[configDynamoDBTableKey] = normalized[configLockTableKey] delete(normalized, configLockTableKey) } return normalized } // ParseExtendedS3Config parses the given map into an extended S3 config. func (cfg Config) ParseExtendedS3Config() (*ExtendedRemoteStateConfigS3, error) { var ( s3Config RemoteStateConfigS3 extendedConfig ExtendedRemoteStateConfigS3 ) if err := mapstructure.WeakDecode(cfg, &s3Config); err != nil { return nil, errors.New(err) } if err := mapstructure.WeakDecode(cfg, &extendedConfig); err != nil { return nil, errors.New(err) } _, targetPrefixExists := cfg[configAccessloggingTargetPrefixKey] if !targetPrefixExists { extendedConfig.AccessLoggingTargetPrefix = DefaultS3BucketAccessLoggingTargetPrefix } extendedConfig.RemoteStateConfigS3 = s3Config return &extendedConfig, nil } // ExtendedS3Config parses the given map into an extended S3 config and validates this config. func (cfg Config) ExtendedS3Config(logger log.Logger) (*ExtendedRemoteStateConfigS3, error) { extS3Cfg, err := cfg.Normalize(logger).ParseExtendedS3Config() if err != nil { return nil, err } return extS3Cfg, extS3Cfg.Validate() } ================================================ FILE: internal/remotestate/backend/s3/config_test.go ================================================ package s3_test import ( "testing" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestParseExtendedS3Config_StringBoolCoercion verifies that boolean config values // passed as strings (e.g. from HCL ternary type unification) are correctly parsed. func TestParseExtendedS3Config_StringBoolCoercion(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config check func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) }{ { "use-lockfile-string-true", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": "true", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.RemoteStateConfigS3.UseLockfile) }, }, { "use-lockfile-string-false", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": "false", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.False(t, cfg.RemoteStateConfigS3.UseLockfile) }, }, { "encrypt-string-true", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "encrypt": "true", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.RemoteStateConfigS3.Encrypt) }, }, { "force-path-style-string-true", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "force_path_style": "true", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.RemoteStateConfigS3.S3ForcePathStyle) }, }, { "skip-bucket-versioning-string-true", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "skip_bucket_versioning": "true", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.SkipBucketVersioning) }, }, { "native-bool-still-works", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": true, }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.RemoteStateConfigS3.UseLockfile) }, }, { "empty-string-coerces-to-false", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": "", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.False(t, cfg.RemoteStateConfigS3.UseLockfile) }, }, { "numeric-one-coerces-to-true", s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": "1", }, func(t *testing.T, cfg *s3backend.ExtendedRemoteStateConfigS3) { t.Helper() assert.True(t, cfg.RemoteStateConfigS3.UseLockfile) }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extS3Cfg, err := tc.config.Normalize(logger.CreateLogger()).ParseExtendedS3Config() require.NoError(t, err) tc.check(t, extS3Cfg) }) } } // TestParseExtendedS3Config_InvalidStringBool verifies that WeakDecode rejects // invalid string values for bool fields (e.g. "maybe" is not a valid bool). func TestParseExtendedS3Config_InvalidStringBool(t *testing.T) { t.Parallel() cfg := s3backend.Config{ "bucket": "my-bucket", "key": "my-key", "region": "us-east-1", "use_lockfile": "maybe", } _, err := cfg.Normalize(logger.CreateLogger()).ParseExtendedS3Config() require.Error(t, err) } ================================================ FILE: internal/remotestate/backend/s3/counting_semaphore.go ================================================ package s3 type empty struct{} type CountingSemaphore chan empty // NewCountingSemaphore is a bare-bones counting semaphore implementation // based on: http://www.golangpatterns.info/concurrency/semaphores func NewCountingSemaphore(size int) CountingSemaphore { return make(CountingSemaphore, size) } func (semaphore CountingSemaphore) Acquire() { semaphore <- empty{} } func (semaphore CountingSemaphore) Release() { <-semaphore } ================================================ FILE: internal/remotestate/backend/s3/counting_semaphore_test.go ================================================ //nolint:govet package s3_test import ( "math/rand" "sync" "sync/atomic" "testing" "time" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" ) func TestAwsCountingSemaphoreHappyPath(t *testing.T) { t.Parallel() semaphore := s3backend.NewCountingSemaphore(1) semaphore.Acquire() semaphore.Release() } // This method tries to verify our counting semaphore works. It does this by creating a counting semaphore of size N // and then firing up M >> N goroutines that all try to Acquire the semaphore. As each goroutine executes, it uses an // atomic increment operation to record how many goroutines are running simultaneously. We check the number of running // goroutines to ensure that it goes up to N, but does not exceed it. func TestAwsCountingSemaphoreConcurrency(t *testing.T) { t.Parallel() permits := 10 goroutines := 100 semaphore := s3backend.NewCountingSemaphore(permits) var ( goRoutinesExecutingSimultaneously uint32 waitForAllGoRoutinesToFinish sync.WaitGroup ) endGoRoutine := func() { // Decrement the number of running goroutines. Note that decrementing an unsigned int is a bit odd. // This is copied from the docs: https://golang.org/pkg/sync/atomic/#AddUint32 atomic.AddUint32(&goRoutinesExecutingSimultaneously, ^uint32(0)) semaphore.Release() waitForAllGoRoutinesToFinish.Done() } runGoRoutine := func() { defer endGoRoutine() semaphore.Acquire() // Increment the total number of running goroutines totalGoRoutinesExecutingSimultaneously := atomic.AddUint32(&goRoutinesExecutingSimultaneously, 1) if totalGoRoutinesExecutingSimultaneously > uint32(permits) { t.Fatalf("The semaphore was only supposed to allow %d goroutines to run simultaneously, but has allowed %d", permits, totalGoRoutinesExecutingSimultaneously) } // Sleep for a random amount of time to represent this goroutine doing work randomSleepTime := rand.Intn(100) time.Sleep(time.Duration(randomSleepTime) * time.Millisecond) } // Fire up a whole bunch of goroutines that will all try to acquire the semaphore at the same time for range goroutines { waitForAllGoRoutinesToFinish.Add(1) go runGoRoutine() } waitForAllGoRoutinesToFinish.Wait() } ================================================ FILE: internal/remotestate/backend/s3/errors.go ================================================ package s3 import "fmt" type MissingRequiredS3RemoteStateConfig string func (configName MissingRequiredS3RemoteStateConfig) Error() string { return "Missing required S3 remote state configuration " + string(configName) } type MultipleTagsDeclarations string func (target MultipleTagsDeclarations) Error() string { return fmt.Sprintf("Tags for %s declared multiple times. Please only declare tags in one block.", string(target)) } type MaxRetriesWaitingForS3BucketExceeded string func (err MaxRetriesWaitingForS3BucketExceeded) Error() string { return fmt.Sprintf("Exceeded max retries (%d) waiting for bucket S3 bucket %s", maxRetriesWaitingForS3Bucket, string(err)) } type MaxRetriesWaitingForS3ACLExceeded string func (err MaxRetriesWaitingForS3ACLExceeded) Error() string { return fmt.Sprintf("Exceeded max retries waiting for S3 bucket %s to have the proper ACL for access logging", string(err)) } type InvalidAccessLoggingBucketEncryption struct { BucketSSEAlgorithm string } func (err InvalidAccessLoggingBucketEncryption) Error() string { return fmt.Sprintf("Encryption algorithm %s is not supported for access logging bucket. Please use a supported algorithm, like AES256", err.BucketSSEAlgorithm) } type TableActiveRetriesExceeded struct { TableName string Retries int } func (err TableActiveRetriesExceeded) Error() string { return fmt.Sprintf("Table %s failed to reach the 'active' state after %d retries.", err.TableName, err.Retries) } type TableDoesNotExist struct { Underlying error TableName string } func (err TableDoesNotExist) Error() string { return fmt.Sprintf("DynamoDB table %s does not exist! Original error from AWS: %v", err.TableName, err.Underlying) } type TableEncryptedRetriesExceeded struct { TableName string Retries int } func (err TableEncryptedRetriesExceeded) Error() string { return fmt.Sprintf("Failed to confirm that DynamoDB table %s has encryption enabled after %d retries.", err.TableName, err.Retries) } ================================================ FILE: internal/remotestate/backend/s3/remote_state_config.go ================================================ package s3 import ( "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/internal/errors" ) // These are settings that can appear in the remote_state config that are ONLY used by Terragrunt and NOT forwarded // to the underlying Terraform backend configuration var terragruntOnlyConfigs = []string{ "s3_bucket_tags", "dynamodb_table_tags", "accesslogging_bucket_tags", "skip_bucket_versioning", "skip_bucket_ssencryption", "skip_bucket_accesslogging", "skip_bucket_root_access", "skip_bucket_enforced_tls", "skip_bucket_public_access_blocking", "disable_bucket_update", "enable_lock_table_ssencryption", "disable_aws_client_checksums", "accesslogging_bucket_name", "accesslogging_target_object_partition_date_source", "accesslogging_target_prefix", "skip_accesslogging_bucket_acl", "skip_accesslogging_bucket_enforced_tls", "skip_accesslogging_bucket_public_access_blocking", "skip_accesslogging_bucket_ssencryption", "bucket_sse_algorithm", "bucket_sse_kms_key_id", } /* ExtendedRemoteStateConfigS3 is a struct that contains the RemoteStateConfigS3 struct and additional * configuration options that are specific to the S3 backend. This struct is used to parse the configuration * from the Terragrunt configuration file. * * We use this construct to separate the three config keys 's3_bucket_tags', 'dynamodb_table_tags' * and 'accesslogging_bucket_tags' from the others, as they are specific to the s3 backend, * but only used by terragrunt to tag the s3 bucket, the dynamo db and the s3 bucket used to the * access logs, in case it has to create them. */ type ExtendedRemoteStateConfigS3 struct { S3BucketTags map[string]string `mapstructure:"s3_bucket_tags"` DynamotableTags map[string]string `mapstructure:"dynamodb_table_tags"` AccessLoggingBucketTags map[string]string `mapstructure:"accesslogging_bucket_tags"` AccessLoggingBucketName string `mapstructure:"accesslogging_bucket_name"` BucketSSEKMSKeyID string `mapstructure:"bucket_sse_kms_key_id"` BucketSSEAlgorithm string `mapstructure:"bucket_sse_algorithm"` AccessLoggingTargetPrefix string `mapstructure:"accesslogging_target_prefix"` AccessLoggingTargetObjectPartitionDateSource string `mapstructure:"accesslogging_target_object_partition_date_source"` RemoteStateConfigS3 RemoteStateConfigS3 `mapstructure:",squash"` SkipBucketVersioning bool `mapstructure:"skip_bucket_versioning"` SkipBucketAccessLogging bool `mapstructure:"skip_bucket_accesslogging"` DisableBucketUpdate bool `mapstructure:"disable_bucket_update"` EnableLockTableSSEncryption bool `mapstructure:"enable_lock_table_ssencryption"` DisableAWSClientChecksums bool `mapstructure:"disable_aws_client_checksums"` SkipBucketEnforcedTLS bool `mapstructure:"skip_bucket_enforced_tls"` SkipBucketRootAccess bool `mapstructure:"skip_bucket_root_access"` SkipBucketPublicAccessBlocking bool `mapstructure:"skip_bucket_public_access_blocking"` SkipAccessLoggingBucketACL bool `mapstructure:"skip_accesslogging_bucket_acl"` SkipAccessLoggingBucketEnforcedTLS bool `mapstructure:"skip_accesslogging_bucket_enforced_tls"` SkipAccessLoggingBucketPublicAccessBlocking bool `mapstructure:"skip_accesslogging_bucket_public_access_blocking"` SkipAccessLoggingBucketSSEncryption bool `mapstructure:"skip_accesslogging_bucket_ssencryption"` SkipBucketSSEncryption bool `mapstructure:"skip_bucket_ssencryption"` SkipCredentialsValidation bool `mapstructure:"skip_credentials_validation"` } func (cfg *ExtendedRemoteStateConfigS3) FetchEncryptionAlgorithm() string { // Encrypt with KMS by default algorithm := string(s3types.ServerSideEncryptionAwsKms) if cfg.BucketSSEAlgorithm != "" { algorithm = cfg.BucketSSEAlgorithm } return algorithm } // GetAwsSessionConfig builds a session config for AWS related requests // from the RemoteStateConfigS3 configuration. func (cfg *ExtendedRemoteStateConfigS3) GetAwsSessionConfig() *awshelper.AwsSessionConfig { s3Endpoint := cfg.RemoteStateConfigS3.Endpoint if cfg.RemoteStateConfigS3.Endpoints.S3 != "" { s3Endpoint = cfg.RemoteStateConfigS3.Endpoints.S3 } dynamoDBEndpoint := cfg.RemoteStateConfigS3.DynamoDBEndpoint if cfg.RemoteStateConfigS3.Endpoints.DynamoDB != "" { dynamoDBEndpoint = cfg.RemoteStateConfigS3.Endpoints.DynamoDB } return &awshelper.AwsSessionConfig{ Region: cfg.RemoteStateConfigS3.Region, CustomS3Endpoint: s3Endpoint, CustomDynamoDBEndpoint: dynamoDBEndpoint, Profile: cfg.RemoteStateConfigS3.Profile, RoleArn: cfg.RemoteStateConfigS3.GetSessionRoleArn(), Tags: cfg.RemoteStateConfigS3.GetSessionTags(), ExternalID: cfg.RemoteStateConfigS3.GetExternalID(), SessionName: cfg.RemoteStateConfigS3.GetSessionName(), CredsFilename: cfg.RemoteStateConfigS3.CredsFilename, S3ForcePathStyle: cfg.RemoteStateConfigS3.S3ForcePathStyle, DisableComputeChecksums: cfg.DisableAWSClientChecksums, } } // CreateS3LoggingInput builds AWS S3 logging input struct from the configuration. func (cfg *ExtendedRemoteStateConfigS3) CreateS3LoggingInput() s3.PutBucketLoggingInput { loggingInput := s3.PutBucketLoggingInput{ Bucket: aws.String(cfg.RemoteStateConfigS3.Bucket), BucketLoggingStatus: &s3types.BucketLoggingStatus{ LoggingEnabled: &s3types.LoggingEnabled{ TargetBucket: aws.String(cfg.AccessLoggingBucketName), }, }, } if cfg.AccessLoggingTargetPrefix != "" { loggingInput.BucketLoggingStatus.LoggingEnabled.TargetPrefix = aws.String(cfg.AccessLoggingTargetPrefix) } if cfg.AccessLoggingTargetObjectPartitionDateSource != "" { loggingInput.BucketLoggingStatus.LoggingEnabled.TargetObjectKeyFormat = &s3types.TargetObjectKeyFormat{ PartitionedPrefix: &s3types.PartitionedPrefix{ PartitionDateSource: s3types.PartitionDateSource(cfg.AccessLoggingTargetObjectPartitionDateSource), }, } } return loggingInput } // Validate validates all the parameters of the given S3 remote state configuration. func (cfg *ExtendedRemoteStateConfigS3) Validate() error { var config = cfg.RemoteStateConfigS3 if config.Region == "" { return errors.New(MissingRequiredS3RemoteStateConfig("region")) } if config.Bucket == "" { return errors.New(MissingRequiredS3RemoteStateConfig("bucket")) } if config.Key == "" { return errors.New(MissingRequiredS3RemoteStateConfig("key")) } return nil } type RemoteStateConfigS3AssumeRole struct { RoleArn string `mapstructure:"role_arn"` Duration string `mapstructure:"duration"` ExternalID string `mapstructure:"external_id"` Policy string `mapstructure:"policy"` PolicyArns []string `mapstructure:"policy_arns"` SessionName string `mapstructure:"session_name"` SourceIdentity string `mapstructure:"source_identity"` Tags map[string]string `mapstructure:"tags"` TransitiveTagKeys []string `mapstructure:"transitive_tag_keys"` } type RemoteStateConfigS3Endpoints struct { S3 string `mapstructure:"s3"` DynamoDB string `mapstructure:"dynamodb"` } // RemoteStateConfigS3 is a representation of the // configuration options available for S3 remote state. type RemoteStateConfigS3 struct { Endpoints RemoteStateConfigS3Endpoints `mapstructure:"endpoints"` RoleArn string `mapstructure:"role_arn"` ExternalID string `mapstructure:"external_id"` Region string `mapstructure:"region"` Endpoint string `mapstructure:"endpoint"` DynamoDBEndpoint string `mapstructure:"dynamodb_endpoint"` Bucket string `mapstructure:"bucket"` Key string `mapstructure:"key"` CredsFilename string `mapstructure:"shared_credentials_file"` Profile string `mapstructure:"profile"` SessionName string `mapstructure:"session_name"` LockTable string `mapstructure:"lock_table"` DynamoDBTable string `mapstructure:"dynamodb_table"` AssumeRole RemoteStateConfigS3AssumeRole `mapstructure:"assume_role"` Encrypt bool `mapstructure:"encrypt"` S3ForcePathStyle bool `mapstructure:"force_path_style"` UseLockfile bool `mapstructure:"use_lockfile"` } // CacheKey returns a unique key for the given S3 config that can be used to cache the initialization func (cfg *RemoteStateConfigS3) CacheKey() string { return fmt.Sprintf( "%s-%s-%s-%s", cfg.Bucket, cfg.Region, cfg.LockTable, cfg.DynamoDBTable, ) } // GetLockTableName returns the name of the DynamoDB table used for locking. // // The DynamoDB lock table attribute used to be called "lock_table", but has since been renamed to "dynamodb_table", and // the old attribute name deprecated. The old attribute name has been eventually removed from Terraform starting with // release 0.13. To maintain backwards compatibility, we support both names. func (cfg *RemoteStateConfigS3) GetLockTableName() string { if cfg.DynamoDBTable != "" { return cfg.DynamoDBTable } return cfg.LockTable } // GetSessionRoleArn returns the role defined in the AssumeRole struct // or fallback to the top level argument deprecated in Terraform 1.6 func (cfg *RemoteStateConfigS3) GetSessionRoleArn() string { if cfg.AssumeRole.RoleArn != "" { return cfg.AssumeRole.RoleArn } return cfg.RoleArn } func (cfg *RemoteStateConfigS3) GetSessionTags() map[string]string { if len(cfg.AssumeRole.Tags) != 0 { return cfg.AssumeRole.Tags } return nil } // GetExternalID returns the external ID defined in the AssumeRole struct // or fallback to the top level argument deprecated in Terraform 1.6 // The external ID is used to prevent confused deputy attacks. func (cfg *RemoteStateConfigS3) GetExternalID() string { if cfg.AssumeRole.ExternalID != "" { return cfg.AssumeRole.ExternalID } return cfg.ExternalID } func (cfg *RemoteStateConfigS3) GetSessionName() string { if cfg.AssumeRole.SessionName != "" { return cfg.AssumeRole.SessionName } return cfg.SessionName } ================================================ FILE: internal/remotestate/backend/s3/remote_state_config_test.go ================================================ package s3_test import ( "bytes" "reflect" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" ) func TestConfig_CreateS3LoggingInput(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config loggingInput s3.PutBucketLoggingInput shouldBeEqual bool }{ { "equal-default-prefix-no-partition-date-source", s3backend.Config{ "bucket": "source-bucket", "accesslogging_bucket_name": "logging-bucket", }, s3.PutBucketLoggingInput{ Bucket: aws.String("source-bucket"), BucketLoggingStatus: &s3types.BucketLoggingStatus{ LoggingEnabled: &s3types.LoggingEnabled{ TargetBucket: aws.String("logging-bucket"), TargetPrefix: aws.String(s3backend.DefaultS3BucketAccessLoggingTargetPrefix), }, }, }, true, }, { "equal-no-prefix-no-partition-date-source", s3backend.Config{ "bucket": "source-bucket", "accesslogging_bucket_name": "logging-bucket", "accesslogging_target_prefix": "", }, s3.PutBucketLoggingInput{ Bucket: aws.String("source-bucket"), BucketLoggingStatus: &s3types.BucketLoggingStatus{ LoggingEnabled: &s3types.LoggingEnabled{ TargetBucket: aws.String("logging-bucket"), }, }, }, true, }, { "equal-custom-prefix-no-partition-date-source", s3backend.Config{ "bucket": "source-bucket", "accesslogging_bucket_name": "logging-bucket", "accesslogging_target_prefix": "custom-prefix/", }, s3.PutBucketLoggingInput{ Bucket: aws.String("source-bucket"), BucketLoggingStatus: &s3types.BucketLoggingStatus{ LoggingEnabled: &s3types.LoggingEnabled{ TargetBucket: aws.String("logging-bucket"), TargetPrefix: aws.String("custom-prefix/"), }, }, }, true, }, { "equal-custom-prefix-custom-partition-date-source", s3backend.Config{ "bucket": "source-bucket", "accesslogging_bucket_name": "logging-bucket", "accesslogging_target_object_partition_date_source": "EventTime", "accesslogging_target_prefix": "custom-prefix/", }, s3.PutBucketLoggingInput{ Bucket: aws.String("source-bucket"), BucketLoggingStatus: &s3types.BucketLoggingStatus{ LoggingEnabled: &s3types.LoggingEnabled{ TargetBucket: aws.String("logging-bucket"), TargetPrefix: aws.String("custom-prefix/"), TargetObjectKeyFormat: &s3types.TargetObjectKeyFormat{ PartitionedPrefix: &s3types.PartitionedPrefix{ PartitionDateSource: s3types.PartitionDateSource("EventTime"), }, }, }, }, }, true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extS3Cfg, err := tc.config.Normalize(log.Default()).ParseExtendedS3Config() require.NoError(t, err, "Unexpected error parsing config for test: %v", err) createdLoggingInput := extS3Cfg.CreateS3LoggingInput() actual := reflect.DeepEqual(createdLoggingInput, tc.loggingInput) if !assert.Equal(t, tc.shouldBeEqual, actual) { t.Errorf("s3.PutBucketLoggingInput mismatch:\ncreated: %+v\nexpected: %+v", createdLoggingInput, tc.loggingInput) } }) } } func TestConfig_ForcePathStyleClientSession(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config expected bool }{ { "path-style-true", s3backend.Config{"force_path_style": true}, true, }, { "path-style-false", s3backend.Config{"force_path_style": false}, false, }, { "path-style-non-existent", s3backend.Config{}, false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extS3Cfg, err := tc.config.Normalize(log.Default()).ParseExtendedS3Config() require.NoError(t, err, "Unexpected error parsing config for test: %v", err) awsSessionConfig := extS3Cfg.GetAwsSessionConfig() actual := awsSessionConfig.S3ForcePathStyle assert.Equal(t, tc.expected, actual) }) } } func TestConfig_CustomStateEndpoints(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config expected *awshelper.AwsSessionConfig }{ { name: "using pre 1.6.x settings only", config: s3backend.Config{"endpoint": "foo", "dynamodb_endpoint": "bar"}, expected: &awshelper.AwsSessionConfig{ CustomS3Endpoint: "foo", CustomDynamoDBEndpoint: "bar", }, }, { name: "using 1.6+ settings", config: s3backend.Config{ "endpoint": "foo", "dynamodb_endpoint": "bar", "endpoints": s3backend.Config{ "s3": "fooBar", "dynamodb": "barFoo", }, }, expected: &awshelper.AwsSessionConfig{ CustomS3Endpoint: "fooBar", CustomDynamoDBEndpoint: "barFoo", }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extS3Cfg, err := tc.config.Normalize(log.Default()).ParseExtendedS3Config() require.NoError(t, err, "Unexpected error parsing config for test: %v", err) actual := extS3Cfg.GetAwsSessionConfig() assert.Equal(t, tc.expected, actual) }) } } func TestConfig_GetAwsSessionConfig(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config }{ { "all-values", s3backend.Config{"region": "foo", "endpoint": "bar", "profile": "baz", "role_arn": "arn::it", "shared_credentials_file": "my-file", "force_path_style": true}, }, { "no-values", s3backend.Config{}, }, { "extra-values", s3backend.Config{"something": "unexpected", "region": "foo", "endpoint": "bar", "dynamodb_endpoint": "foobar", "profile": "baz", "role_arn": "arn::it", "shared_credentials_file": "my-file", "force_path_style": false}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() extS3Cfg, err := tc.config.Normalize(log.Default()).ParseExtendedS3Config() require.NoError(t, err, "Unexpected error parsing config for test: %v", err) expected := &awshelper.AwsSessionConfig{ Region: extS3Cfg.RemoteStateConfigS3.Region, CustomS3Endpoint: extS3Cfg.RemoteStateConfigS3.Endpoint, CustomDynamoDBEndpoint: extS3Cfg.RemoteStateConfigS3.DynamoDBEndpoint, Profile: extS3Cfg.RemoteStateConfigS3.Profile, RoleArn: extS3Cfg.RemoteStateConfigS3.RoleArn, CredsFilename: extS3Cfg.RemoteStateConfigS3.CredsFilename, S3ForcePathStyle: extS3Cfg.RemoteStateConfigS3.S3ForcePathStyle, DisableComputeChecksums: extS3Cfg.DisableAWSClientChecksums, } actual := extS3Cfg.GetAwsSessionConfig() assert.Equal(t, expected, actual) }) } } func TestConfig_GetAwsSessionConfigWithAssumeRole(t *testing.T) { t.Parallel() testCases := []struct { //nolint: govet name string config s3backend.Config }{ { "all-values", s3backend.Config{"role_arn": "arn::it", "external_id": "123", "session_name": "foobar", "tags": map[string]string{"foo": "bar"}}, }, { "no-tags", s3backend.Config{"role_arn": "arn::it", "external_id": "123", "session_name": "foobar"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() config := s3backend.Config{"assume_role": tc.config} extS3Cfg, err := config.Normalize(log.Default()).ParseExtendedS3Config() require.NoError(t, err, "Unexpected error parsing config for test: %v", err) expected := &awshelper.AwsSessionConfig{ RoleArn: extS3Cfg.RemoteStateConfigS3.AssumeRole.RoleArn, ExternalID: extS3Cfg.RemoteStateConfigS3.AssumeRole.ExternalID, SessionName: extS3Cfg.RemoteStateConfigS3.AssumeRole.SessionName, Tags: extS3Cfg.RemoteStateConfigS3.AssumeRole.Tags, } actual := extS3Cfg.GetAwsSessionConfig() assert.Equal(t, expected, actual) }) } } func TestConfig_Validate(t *testing.T) { t.Parallel() testCases := []struct { name string extConfig *s3backend.ExtendedRemoteStateConfigS3 expectedErr error expectedOutput string }{ { name: "no-region", extConfig: &s3backend.ExtendedRemoteStateConfigS3{}, expectedErr: s3backend.MissingRequiredS3RemoteStateConfig("region"), }, { name: "no-bucket", extConfig: &s3backend.ExtendedRemoteStateConfigS3{ RemoteStateConfigS3: s3backend.RemoteStateConfigS3{ Region: "us-west-2", }, }, expectedErr: s3backend.MissingRequiredS3RemoteStateConfig("bucket"), }, { name: "no-key", extConfig: &s3backend.ExtendedRemoteStateConfigS3{ RemoteStateConfigS3: s3backend.RemoteStateConfigS3{ Region: "us-west-2", Bucket: "state-bucket", }, }, expectedErr: s3backend.MissingRequiredS3RemoteStateConfig("key"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() buf := &bytes.Buffer{} logger := logrus.New() logger.SetLevel(logrus.DebugLevel) logger.SetOutput(buf) err := tc.extConfig.Validate() require.ErrorIs(t, err, tc.expectedErr) assert.Contains(t, buf.String(), tc.expectedOutput) }) } } ================================================ FILE: internal/remotestate/backend/s3/retryer.go ================================================ package s3 import ( "github.com/aws/aws-sdk-go-v2/aws" ) type Retryer struct { aws.Retryer } // IsErrorRetryable checks if the given error is retryable according to AWS SDK v2 retry logic. // AWS SDK v2 doesn't expose the same retry helper functions as v1 // The retry logic is handled internally by the SDK // This is a simplified retryer that delegates to the underlying AWS retryer func (retryer Retryer) IsErrorRetryable(err error) bool { return retryer.Retryer.IsErrorRetryable(err) } ================================================ FILE: internal/remotestate/config.go ================================================ package remotestate import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/zclconf/go-cty/cty" ) var ( ErrRemoteBackendMissing = errors.New("the remote_state.backend field cannot be empty") ErrGenerateCalledWithNoGenerateAttr = errors.New("generate code routine called when no generate attribute is configured") ) // ConfigGenerate is code gen configuration for Terraform remote state. type ConfigGenerate struct { Path string `cty:"path" mapstructure:"path"` IfExists string `cty:"if_exists" mapstructure:"if_exists"` } // Config is the configuration for Terraform remote state. // NOTE: If any attributes are added here, be sure to add it to `ConfigFile` struct. type Config struct { BackendConfig backend.Config `mapstructure:"config" json:"Config"` Generate *ConfigGenerate `mapstructure:"generate" json:"Generate"` Encryption map[string]any `mapstructure:"encryption" json:"Encryption"` BackendName string `mapstructure:"backend" json:"Backend"` DisableInit bool `mapstructure:"disable_init" json:"DisableInit"` DisableDependencyOptimization bool `mapstructure:"disable_dependency_optimization" json:"DisableDependencyOptimization"` } func (cfg *Config) String() string { return fmt.Sprintf( "RemoteState{Backend = %v, DisableInit = %v, DisableDependencyOptimization = %v, Generate = %v, Config = %v, Encryption = %v}", cfg.BackendName, cfg.DisableInit, cfg.DisableDependencyOptimization, cfg.Generate, cfg.BackendConfig, cfg.Encryption, ) } // Validate validates that the remote state is configured correctly. func (cfg *Config) Validate() error { if cfg.BackendName == "" { return errors.New(ErrRemoteBackendMissing) } return nil } // GenerateOpenTofuCode generates the OpenTofu/Terraform code for configuring remote state backend. func (cfg *Config) GenerateOpenTofuCode(l log.Logger, workingDir string, backendConfig map[string]any) error { if cfg.Generate == nil { return errors.New(ErrGenerateCalledWithNoGenerateAttr) } switch { case cfg.Encryption == nil: l.Debug("No encryption block in remote_state config") case len(cfg.Encryption) == 0: l.Debug("Empty encryption block in remote_state config") default: _, ok := cfg.Encryption[codegen.EncryptionKeyProviderKey].(string) if !ok { return errors.New("key_provider not found in encryption config") } } // Convert the IfExists setting to the internal enum representation before calling generate. ifExistsEnum, err := codegen.GenerateConfigExistsFromString(cfg.Generate.IfExists) if err != nil { return err } configBytes, err := codegen.RemoteStateConfigToTerraformCode(cfg.BackendName, backendConfig, cfg.Encryption) if err != nil { return err } codegenConfig := codegen.GenerateConfig{ Path: cfg.Generate.Path, IfExists: ifExistsEnum, IfExistsStr: cfg.Generate.IfExists, Contents: string(configBytes), CommentPrefix: codegen.DefaultCommentPrefix, } return codegen.WriteToFile(l, workingDir, &codegenConfig) } type ConfigFileGenerate struct { // We use cty instead of hcl, since we are using this type to convert an attr and not a block. Path string `cty:"path"` IfExists string `cty:"if_exists"` } // ConfigFile is configuration for Terraform remote state as parsed from a terragrunt.hcl config file. type ConfigFile struct { BackendConfig cty.Value `hcl:"config,attr"` DisableInit *bool `hcl:"disable_init,attr"` DisableDependencyOptimization *bool `hcl:"disable_dependency_optimization,attr"` Generate *ConfigFileGenerate `hcl:"generate,attr"` Encryption *cty.Value `hcl:"encryption,attr"` BackendName string `hcl:"backend,attr"` } func (cfgFile *ConfigFile) String() string { return fmt.Sprintf("ConfigFile{Backend = %v, Config = %v}", cfgFile.BackendName, cfgFile.BackendConfig, ) } // Config converts the parsed config file remote state struct to the internal representation struct of remote state // configurations. func (cfgFile *ConfigFile) Config() (*Config, error) { remoteStateConfig, err := ctyhelper.ParseCtyValueToMap(cfgFile.BackendConfig) if err != nil { return nil, err } cfg := &Config{} cfg.BackendName = cfgFile.BackendName if cfgFile.Generate != nil { cfg.Generate = &ConfigGenerate{ Path: cfgFile.Generate.Path, IfExists: cfgFile.Generate.IfExists, } } cfg.BackendConfig = remoteStateConfig if cfgFile.Encryption != nil && !cfgFile.Encryption.IsNull() { remoteStateEncryption, err := ctyhelper.ParseCtyValueToMap(*cfgFile.Encryption) if err != nil { return nil, err } cfg.Encryption = remoteStateEncryption } else { cfg.Encryption = nil } if cfgFile.DisableInit != nil { cfg.DisableInit = *cfgFile.DisableInit } if cfgFile.DisableDependencyOptimization != nil { cfg.DisableDependencyOptimization = *cfgFile.DisableDependencyOptimization } return cfg, cfg.Validate() } ================================================ FILE: internal/remotestate/remote_state.go ================================================ // Package remotestate contains code for configuring remote state storage. package remotestate import ( "context" "fmt" "os" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/gcs" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" ) var backends = backend.Backends{ s3.NewBackend(), gcs.NewBackend(), } // Options contains the subset of configuration needed by RemoteState operations. type Options struct { TFRunOpts *tf.TFOptions backend.Options DisableBucketUpdate bool } // RemoteState is the configuration for Terraform remote state. type RemoteState struct { *Config `mapstructure:",squash"` backend backend.Backend } // New creates a new `RemoteState` instance. func New(config *Config) *RemoteState { remote := &RemoteState{ Config: config, backend: backend.NewCommonBackend(config.BackendName), } if backend := backends.Get(config.BackendName); backend != nil { remote.backend = backend } return remote } // String implements `fmt.Stringer` interface. func (remote *RemoteState) String() string { return remote.Config.String() } func (remote *RemoteState) IsVersionControlEnabled(ctx context.Context, l log.Logger, opts *Options) (bool, error) { l.Debugf("Checking if version control is enabled for the %s backend", remote.BackendName) return remote.backend.IsVersionControlEnabled(ctx, l, remote.BackendConfig, &opts.Options) } // Delete deletes the remote state. func (remote *RemoteState) Delete(ctx context.Context, l log.Logger, opts *Options) error { l.Debugf("Deleting remote state for the %s backend", remote.BackendName) return remote.backend.Delete(ctx, l, remote.BackendConfig, &opts.Options) } // DeleteBucket deletes the entire bucket. func (remote *RemoteState) DeleteBucket(ctx context.Context, l log.Logger, opts *Options) error { l.Debugf("Deleting the entire bucket for the %s backend", remote.BackendName) return remote.backend.DeleteBucket(ctx, l, remote.BackendConfig, &opts.Options) } // Bootstrap performs any actions necessary to bootstrap remote state before it's used for storage. For example, if you're // using S3 or GCS for remote state storage, this may create the bucket if it doesn't exist already. func (remote *RemoteState) Bootstrap(ctx context.Context, l log.Logger, opts *Options) error { l.Debugf("Bootstrapping remote state for the %s backend", remote.BackendName) return remote.backend.Bootstrap(ctx, l, remote.BackendConfig, &opts.Options) } // Migrate determines where the remote state resources exist for source backend config and migrate them to dest backend config. func (remote *RemoteState) Migrate(ctx context.Context, l log.Logger, opts, dstOpts *Options, dstRemote *RemoteState) error { l.Debugf("Migrate remote state for the %s backend", remote.BackendName) if remote.BackendName == dstRemote.BackendName { return remote.backend.Migrate(ctx, l, remote.BackendConfig, dstRemote.BackendConfig, &opts.Options) } stateFile, err := remote.pullState(ctx, l, opts.TFRunOpts) if err != nil { return err } defer func() { os.Remove(stateFile) // nolint: errcheck }() return dstRemote.pushState(ctx, l, dstOpts.TFRunOpts, stateFile) } // NeedsBootstrap returns true if remote state needs to be configured. This will be the case when: // // 1. Remote state auto-initialization has been disabled. // 2. Remote state has not already been configured. // 3. Remote state has been configured, but with a different configuration. // 4. The remote state bootstrapper for this backend type, if there is one, says bootstrap is necessary. func (remote *RemoteState) NeedsBootstrap(ctx context.Context, l log.Logger, opts *Options) (bool, error) { if opts.DisableBucketUpdate { l.Debug("Skipping remote state bootstrap") return false, nil } if remote.DisableInit { return false, nil } // The specific backend type will check if bootstrap is necessary. l.Debugf("Checking if remote state bootstrap is necessary for the %s backend", remote.BackendName) return remote.backend.NeedsBootstrap(ctx, l, remote.BackendConfig, &opts.Options) } // GetTFInitArgs converts the RemoteState config into the format used by the `tofu init` command. func (remote *RemoteState) GetTFInitArgs() []string { if remote.Generate != nil { // When in generate mode, we don't need to use `-backend-config` to initialize the remote state backend. return []string{} } config := remote.backend.GetTFInitArgs(remote.BackendConfig) var backendConfigArgs = make([]string, 0, len(config)) for key, value := range config { arg := fmt.Sprintf("-backend-config=%s=%v", key, value) backendConfigArgs = append(backendConfigArgs, arg) } return backendConfigArgs } // GenerateOpenTofuCode generates the OpenTofu/Terraform code for configuring remote state backend. func (remote *RemoteState) GenerateOpenTofuCode(l log.Logger, workingDir string) error { backendConfig := remote.backend.GetTFInitArgs(remote.BackendConfig) return remote.Config.GenerateOpenTofuCode(l, workingDir, backendConfig) } func (remote *RemoteState) pullState(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions) (string, error) { l.Debugf("Pulling state from %s backend", remote.BackendName) args := []string{tf.CommandNameState, tf.CommandNamePull} output, err := tf.RunCommandWithOutput(ctx, l, tfOpts, args...) if err != nil { return "", err } l.Debugf("Creating temporary state file for migration") file, err := os.CreateTemp("", "*.tfstate") if err != nil { return "", errors.New(err) } defer func() { file.Close() // nolint: errcheck }() if _, err := file.Write(output.Stdout.Bytes()); err != nil { return file.Name(), errors.New(err) } return file.Name(), nil } func (remote *RemoteState) pushState(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, stateFile string) error { l.Debugf("Pushing state to %s backend", remote.BackendName) args := []string{tf.CommandNameState, tf.CommandNamePush, stateFile} return tf.RunCommand(ctx, l, tfOpts, args...) } ================================================ FILE: internal/remotestate/remote_state_test.go ================================================ package remotestate_test import ( "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) /** * Test for s3, also tests that the terragrunt-specific options are not passed on to terraform */ func TestGetTFInitArgs(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: "s3", BackendConfig: map[string]any{ "encrypt": true, "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "s3_bucket_tags": map[string]any{ "team": "team name", "name": "Terraform state storage", "service": "Terraform"}, "dynamodb_table_tags": map[string]any{ "team": "team name", "name": "Terraform lock table", "service": "Terraform"}, "accesslogging_bucket_tags": map[string]any{ "team": "team name", "name": "Terraform access log storage", "service": "Terraform"}, "skip_bucket_versioning": true, "shared_credentials_file": "my-file", "force_path_style": true, }, } args := remotestate.New(cfg).GetTFInitArgs() // must not contain s3_bucket_tags or dynamodb_table_tags or accesslogging_bucket_tags or skip_bucket_versioning assertTerraformInitArgsEqual(t, args, "-backend-config=encrypt=true -backend-config=bucket=my-bucket -backend-config=key=terraform.tfstate -backend-config=region=us-east-1 -backend-config=force_path_style=true -backend-config=shared_credentials_file=my-file") } func TestGetTFInitArgsForGCS(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: "gcs", BackendConfig: map[string]any{ "project": "my-project-123456", "location": "US", "bucket": "my-bucket", "prefix": "terraform.tfstate", "gcs_bucket_labels": map[string]any{ "team": "team name", "name": "Terraform state storage", "service": "Terraform"}, "skip_bucket_versioning": true, "credentials": "my-file", "access_token": "xxxxxxxx", }, } args := remotestate.New(cfg).GetTFInitArgs() // must not contain project, location gcs_bucket_labels or skip_bucket_versioning assertTerraformInitArgsEqual(t, args, "-backend-config=bucket=my-bucket -backend-config=prefix=terraform.tfstate -backend-config=credentials=my-file -backend-config=access_token=xxxxxxxx") } func TestGetTFInitArgsUnknownBackend(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: "s4", BackendConfig: map[string]any{ "encrypt": true, "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1"}, } args := remotestate.New(cfg).GetTFInitArgs() // no Backend initializer available, but command line args should still be passed on assertTerraformInitArgsEqual(t, args, "-backend-config=encrypt=true -backend-config=bucket=my-bucket -backend-config=key=terraform.tfstate -backend-config=region=us-east-1") } func TestGetTFInitArgsInitDisabled(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: "s3", DisableInit: true, BackendConfig: map[string]any{ "encrypt": true, "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1"}, } args := remotestate.New(cfg).GetTFInitArgs() assertTerraformInitArgsEqual(t, args, "-backend-config=encrypt=true -backend-config=bucket=my-bucket -backend-config=key=terraform.tfstate -backend-config=region=us-east-1") } func TestGetTFInitArgsNoBackendConfigs(t *testing.T) { t.Parallel() cfgs := []*remotestate.Config{ {BackendName: "s3"}, {BackendName: "gcs"}, } for _, cfg := range cfgs { args := remotestate.New(cfg).GetTFInitArgs() assert.Empty(t, args) } } // TestGetTFInitArgs_StringBoolCoercion verifies that string boolean values // (from HCL ternary type unification) pass through correctly to terraform init -backend-config args. func TestGetTFInitArgs_StringBoolCoercion(t *testing.T) { t.Parallel() testCases := []struct { name string backendName string config map[string]any expectedArgs []string }{ { "s3-string-bool-use-lockfile", "s3", map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "encrypt": "true", "use_lockfile": "true", }, []string{ "-backend-config=bucket=my-bucket", "-backend-config=key=terraform.tfstate", "-backend-config=region=us-east-1", "-backend-config=encrypt=true", "-backend-config=use_lockfile=true", }, }, { "s3-native-bool-use-lockfile", "s3", map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "encrypt": true, "use_lockfile": true, }, []string{ "-backend-config=bucket=my-bucket", "-backend-config=key=terraform.tfstate", "-backend-config=region=us-east-1", "-backend-config=encrypt=true", "-backend-config=use_lockfile=true", }, }, { "s3-string-bool-false", "s3", map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", "use_lockfile": "false", }, []string{ "-backend-config=bucket=my-bucket", "-backend-config=key=terraform.tfstate", "-backend-config=region=us-east-1", "-backend-config=use_lockfile=false", }, }, { "gcs-string-bool-skip-versioning", "gcs", map[string]any{ "bucket": "my-bucket", "prefix": "terraform.tfstate", "skip_bucket_versioning": "true", }, []string{ "-backend-config=bucket=my-bucket", "-backend-config=prefix=terraform.tfstate", }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: tc.backendName, BackendConfig: tc.config, } args := remotestate.New(cfg).GetTFInitArgs() assert.ElementsMatch(t, tc.expectedArgs, args) }) } } func TestNeedsBootstrapDisableInit(t *testing.T) { t.Parallel() cfg := &remotestate.Config{ BackendName: "s3", DisableInit: true, BackendConfig: map[string]any{ "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1", }, } remote := remotestate.New(cfg) needsBootstrap, err := remote.NeedsBootstrap(t.Context(), logger.CreateLogger(), &remotestate.Options{}) require.NoError(t, err) assert.False(t, needsBootstrap, "NeedsBootstrap must return false when DisableInit=true") } func assertTerraformInitArgsEqual(t *testing.T, actualArgs []string, expectedArgs string) { t.Helper() expected := strings.Split(expectedArgs, " ") assert.Len(t, actualArgs, len(expected)) for _, expectedArg := range expected { assert.Contains(t, actualArgs, expectedArg) } } ================================================ FILE: internal/remotestate/terraform_state_file.go ================================================ package remotestate import ( "encoding/json" "fmt" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/util" ) // TODO: this file could be changed to use the Terraform Go code to read state files, but that code is relatively // complicated and doesn't seem to be designed for standalone use. Fortunately, the .tfstate format is a fairly simple // JSON format, so hopefully this simple parsing code will not be a maintenance burden. // DefaultPathToLocalStateFile is the default path to the tfstate file when storing Terraform state locally. const DefaultPathToLocalStateFile = "terraform.tfstate" // DefaultPathToRemoteStateFile is the default folder location for the local copy of the state file when using remote // state storage in Terraform. const DefaultPathToRemoteStateFile = "terraform.tfstate" // TerraformState - represents the structure of the Terraform .tfstate file. type TerraformState struct { Backend *TerraformBackend `json:"Backend"` Modules []TerraformStateModule `json:"Modules"` Version int `json:"Version"` Serial int `json:"Serial"` } // TerraformBackend represents the structure of the "backend" section in the Terraform .tfstate file. type TerraformBackend struct { Config map[string]any `json:"Config"` Type string `json:"Type"` } // TerraformStateModule represents the structure of a "module" section in the Terraform .tfstate file. type TerraformStateModule struct { Outputs map[string]any `json:"Outputs"` Resources map[string]any `json:"Resources"` Path []string `json:"Path"` } // IsRemote returns true if this Terraform state is configured for remote state storage. func (state *TerraformState) IsRemote() bool { return state != nil && state.Backend != nil && state.Backend.Type != "local" } // ParseTerraformStateFileFromLocation parses the Terraform .tfstate file. If a local backend is used then search // the given path, or return nil if the file is missing. If the backend is not local then parse the Terraform .tfstate // file from the location specified by workingDir. If no location is specified, search the current // directory. If the file doesn't exist at any of the default locations, return nil. func ParseTerraformStateFileFromLocation(backend string, config backend.Config, workingDir, dataDir string) (*TerraformState, error) { if stateFile := config.Path(); backend == "local" && stateFile != "" && util.FileExists(stateFile) { return ParseTerraformStateFile(stateFile) } if util.FileExists(filepath.Join(dataDir, DefaultPathToRemoteStateFile)) { return ParseTerraformStateFile(filepath.Join(dataDir, DefaultPathToRemoteStateFile)) } if util.FileExists(filepath.Join(workingDir, DefaultPathToLocalStateFile)) { return ParseTerraformStateFile(filepath.Join(workingDir, DefaultPathToLocalStateFile)) } return nil, nil } // ParseTerraformStateFile parses the Terraform .tfstate file located at the specified path. func ParseTerraformStateFile(path string) (*TerraformState, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, errors.New(CantParseTerraformStateFileError{Path: path, UnderlyingErr: err}) } state, err := ParseTerraformState(bytes) if err != nil { return nil, errors.New(CantParseTerraformStateFileError{Path: path, UnderlyingErr: err}) } return state, nil } // ParseTerraformState parses the Terraform state file data from the provided byte slice. func ParseTerraformState(terraformStateData []byte) (*TerraformState, error) { terraformState := &TerraformState{} if len(terraformStateData) == 0 { return terraformState, nil } if err := json.Unmarshal(terraformStateData, terraformState); err != nil { return nil, errors.New(err) } return terraformState, nil } // CantParseTerraformStateFileError error that occurs when we can't parse the Terraform state file. type CantParseTerraformStateFileError struct { UnderlyingErr error Path string } func (err CantParseTerraformStateFileError) Error() string { return fmt.Sprintf("Error parsing Terraform state file %s: %s", err.Path, err.UnderlyingErr.Error()) } ================================================ FILE: internal/remotestate/terraform_state_file_test.go ================================================ package remotestate_test import ( "encoding/json" "testing" "errors" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseTerraformStateLocal(t *testing.T) { t.Parallel() stateFile := ` { "version": 1, "serial": 0, "modules": [ { "path": [ "root" ], "outputs": {}, "resources": {} } ] } ` expectedTerraformState := &remotestate.TerraformState{ Version: 1, Serial: 0, Backend: nil, Modules: []remotestate.TerraformStateModule{ { Path: []string{"root"}, Outputs: map[string]any{}, Resources: map[string]any{}, }, }, } actualTerraformState, err := remotestate.ParseTerraformState([]byte(stateFile)) require.NoError(t, err) assert.Equal(t, expectedTerraformState, actualTerraformState) assert.False(t, actualTerraformState.IsRemote()) } func TestParseTerraformStateRemote(t *testing.T) { t.Parallel() stateFile := ` { "version": 5, "serial": 12, "backend": { "type": "s3", "config": { "bucket": "bucket", "encrypt": true, "key": "experiment-1.tfstate", "region": "us-east-1" } }, "modules": [ { "path": [ "root" ], "outputs": {}, "resources": {} } ] } ` expectedTerraformState := &remotestate.TerraformState{ Version: 5, Serial: 12, Backend: &remotestate.TerraformBackend{ Type: "s3", Config: map[string]any{ "bucket": "bucket", "encrypt": true, "key": "experiment-1.tfstate", "region": "us-east-1", }, }, Modules: []remotestate.TerraformStateModule{ { Path: []string{"root"}, Outputs: map[string]any{}, Resources: map[string]any{}, }, }, } actualTerraformState, err := remotestate.ParseTerraformState([]byte(stateFile)) require.NoError(t, err) assert.Equal(t, expectedTerraformState, actualTerraformState) assert.True(t, actualTerraformState.IsRemote()) } func TestParseTerraformStateRemoteFull(t *testing.T) { t.Parallel() // This is a small snippet (with lots of editing) of Terraform templates that created a VPC stateFile := ` { "version": 1, "serial": 51, "backend": { "type": "s3", "config": { "bucket": "bucket", "encrypt": true, "key": "terraform.tfstate", "region": "us-east-1" } }, "modules": [ { "path": [ "root" ], "outputs": { "key1": "value1", "key2": "value2", "key3": "value3" }, "resources": {} }, { "path": [ "root", "module_with_outputs_no_resources" ], "outputs": { "key1": "", "key2": "" }, "resources": {} }, { "path": [ "root", "module_with_resources_no_outputs" ], "outputs": {}, "resources": { "aws_eip.nat.0": { "type": "aws_eip", "depends_on": [ "aws_internet_gateway.main" ], "primary": { "id": "eipalloc-b421becd", "attributes": { "association_id": "", "domain": "vpc", "id": "eipalloc-b421becd", "instance": "", "network_interface": "", "private_ip": "", "public_ip": "23.20.182.117", "vpc": "true" } } }, "aws_eip.nat.1": { "type": "aws_eip", "depends_on": [ "aws_internet_gateway.main" ], "primary": { "id": "eipalloc-95d846ec", "attributes": { "association_id": "", "domain": "vpc", "id": "eipalloc-95d846ec", "instance": "", "network_interface": "", "private_ip": "", "public_ip": "52.21.82.253", "vpc": "true" } } } } }, { "path": [ "root", "module_level_1", "module_level_2" ], "outputs": {}, "resources": {} } ] } ` expectedTerraformState := &remotestate.TerraformState{ Version: 1, Serial: 51, Backend: &remotestate.TerraformBackend{ Type: "s3", Config: map[string]any{ "bucket": "bucket", "encrypt": true, "key": "terraform.tfstate", "region": "us-east-1", }, }, Modules: []remotestate.TerraformStateModule{ { Path: []string{"root"}, Outputs: map[string]any{ "key1": "value1", "key2": "value2", "key3": "value3", }, Resources: map[string]any{}, }, { Path: []string{"root", "module_with_outputs_no_resources"}, Outputs: map[string]any{ "key1": "", "key2": "", }, Resources: map[string]any{}, }, { Path: []string{"root", "module_with_resources_no_outputs"}, Outputs: map[string]any{}, Resources: map[string]any{ "aws_eip.nat.0": map[string]any{ "type": "aws_eip", "depends_on": []any{"aws_internet_gateway.main"}, "primary": map[string]any{ "id": "eipalloc-b421becd", "attributes": map[string]any{ "association_id": "", "domain": "vpc", "id": "eipalloc-b421becd", "instance": "", "network_interface": "", "private_ip": "", "public_ip": "23.20.182.117", "vpc": "true", }, }, }, "aws_eip.nat.1": map[string]any{ "type": "aws_eip", "depends_on": []any{"aws_internet_gateway.main"}, "primary": map[string]any{ "id": "eipalloc-95d846ec", "attributes": map[string]any{ "association_id": "", "domain": "vpc", "id": "eipalloc-95d846ec", "instance": "", "network_interface": "", "private_ip": "", "public_ip": "52.21.82.253", "vpc": "true", }, }, }, }, }, { Path: []string{"root", "module_level_1", "module_level_2"}, Outputs: map[string]any{}, Resources: map[string]any{}, }, }, } actualTerraformState, err := remotestate.ParseTerraformState([]byte(stateFile)) require.NoError(t, err) assert.Equal(t, expectedTerraformState, actualTerraformState) assert.True(t, actualTerraformState.IsRemote()) } func TestParseTerraformStateEmpty(t *testing.T) { t.Parallel() stateFile := `{}` expectedTerraformState := &remotestate.TerraformState{} actualTerraformState, err := remotestate.ParseTerraformState([]byte(stateFile)) require.NoError(t, err) assert.Equal(t, expectedTerraformState, actualTerraformState) assert.False(t, actualTerraformState.IsRemote()) } func TestParseTerraformStateInvalid(t *testing.T) { t.Parallel() stateFile := `not-valid-json` actualTerraformState, err := remotestate.ParseTerraformState([]byte(stateFile)) assert.Nil(t, actualTerraformState) require.Error(t, err) var jsonSyntaxError *json.SyntaxError ok := errors.As(err, &jsonSyntaxError) assert.True(t, ok) } ================================================ FILE: internal/report/colors.go ================================================ package report import ( "fmt" "time" "github.com/mgutz/ansi" ) // Colorizer is a colorizer for the run summary output. type Colorizer struct { headingTitleColorizer func(string) string headingUnitColorizer func(string) string successColorizer func(string) string failureColorizer func(string) string exitColorizer func(string) string excludeColorizer func(string) string successUnitColorizer func(string) string failureUnitColorizer func(string) string exitUnitColorizer func(string) string excludeUnitColorizer func(string) string nanosecondColorizer func(string) string microsecondColorizer func(string) string millisecondColorizer func(string) string secondColorizer func(string) string minuteColorizer func(string) string defaultColorizer func(string) string paddingColorizer func(string) string } // NewColorizer creates a new Colorizer. func NewColorizer(shouldColor bool) *Colorizer { if !shouldColor { return &Colorizer{ headingTitleColorizer: func(s string) string { return s }, headingUnitColorizer: func(s string) string { return s }, successColorizer: func(s string) string { return s }, failureColorizer: func(s string) string { return s }, exitColorizer: func(s string) string { return s }, excludeColorizer: func(s string) string { return s }, successUnitColorizer: func(s string) string { return s }, failureUnitColorizer: func(s string) string { return s }, exitUnitColorizer: func(s string) string { return s }, excludeUnitColorizer: func(s string) string { return s }, nanosecondColorizer: func(s string) string { return s }, microsecondColorizer: func(s string) string { return s }, millisecondColorizer: func(s string) string { return s }, secondColorizer: func(s string) string { return s }, minuteColorizer: func(s string) string { return s }, defaultColorizer: func(s string) string { return s }, paddingColorizer: func(s string) string { return s }, } } // Define unit colorizers based on environment variable var successUnitColorizer, failureUnitColorizer, exitUnitColorizer, excludeUnitColorizer func(string) string if shouldColor { successUnitColorizer = ansi.ColorFunc("green+h") failureUnitColorizer = ansi.ColorFunc("red+h") exitUnitColorizer = ansi.ColorFunc("yellow+h") excludeUnitColorizer = ansi.ColorFunc("blue+h") } else { successUnitColorizer = func(s string) string { return s } failureUnitColorizer = func(s string) string { return s } exitUnitColorizer = func(s string) string { return s } excludeUnitColorizer = func(s string) string { return s } } return &Colorizer{ headingTitleColorizer: ansi.ColorFunc("yellow+bh"), headingUnitColorizer: ansi.ColorFunc("white+bh"), successColorizer: ansi.ColorFunc("green+bh"), failureColorizer: ansi.ColorFunc("red+bh"), exitColorizer: ansi.ColorFunc("yellow+bh"), excludeColorizer: ansi.ColorFunc("blue+bh"), successUnitColorizer: successUnitColorizer, failureUnitColorizer: failureUnitColorizer, exitUnitColorizer: exitUnitColorizer, excludeUnitColorizer: excludeUnitColorizer, nanosecondColorizer: ansi.ColorFunc("cyan+bh"), microsecondColorizer: ansi.ColorFunc("cyan+bh"), millisecondColorizer: ansi.ColorFunc("cyan+bh"), secondColorizer: ansi.ColorFunc("green+bh"), minuteColorizer: ansi.ColorFunc("yellow+bh"), defaultColorizer: ansi.ColorFunc("white+bh"), paddingColorizer: ansi.ColorFunc("gray"), } } // colorDuration returns the duration as a string, colored based on the duration. func (c *Colorizer) colorDuration(duration time.Duration) string { // if duration is negative, return "N/A" in default color if duration < 0 { return c.defaultColorizer("N/A") } if duration < time.Microsecond { return c.nanosecondColorizer(fmt.Sprintf("%dns", duration.Nanoseconds())) } if duration < time.Millisecond { return c.microsecondColorizer(fmt.Sprintf("%dµs", duration.Microseconds())) } if duration < time.Second { return c.millisecondColorizer(fmt.Sprintf("%dms", duration.Milliseconds())) } if duration < time.Minute { return c.secondColorizer(fmt.Sprintf("%ds", int(duration.Seconds()))) } return c.minuteColorizer(fmt.Sprintf("%dm", int(duration.Minutes()))) } ================================================ FILE: internal/report/report.go ================================================ // Package report provides a mechanism for collecting data on runs and generating a reports and summaries on that data. package report import ( "errors" "fmt" "path/filepath" "slices" "sync" "time" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Report captures data for a report/summary. type Report struct { workingDir string format Format Runs []*Run mu sync.RWMutex shouldColor bool showUnitLevelSummary bool } // Run captures data for a run. type Run struct { Started time.Time Ended time.Time Reason *Reason Cause *Cause Path string Result Result DiscoveryWorkingDir string Ref string Cmd string Args []string mu sync.RWMutex } // Result captures the result of a run. type Result string // Reason captures the reason for a run. type Reason string // Cause captures the cause of a run. type Cause string // Format captures the format of a report. type Format string const ( FormatCSV Format = "csv" FormatJSON Format = "json" ) const ( ResultSucceeded Result = "succeeded" ResultFailed Result = "failed" ResultEarlyExit Result = "early exit" ResultExcluded Result = "excluded" ) const ( ReasonRetrySucceeded Reason = "retry succeeded" ReasonErrorIgnored Reason = "error ignored" ReasonRunError Reason = "run error" ReasonExcludeBlock Reason = "exclude block" ReasonAncestorError Reason = "ancestor error" ) // NewReport creates a new report. func NewReport() *Report { report := &Report{ Runs: make([]*Run, 0), shouldColor: true, } return report } // NewReportOption is an option for creating a new report. type NewReportOption func(*Report) // WithDisableColor sets the shouldColor flag for the report. func (r *Report) WithDisableColor() *Report { r.shouldColor = false return r } // WithWorkingDir sets the working directory for the report. func (r *Report) WithWorkingDir(workingDir string) *Report { r.workingDir = workingDir return r } // WithFormat sets the format for the report. func (r *Report) WithFormat(format Format) *Report { r.format = format return r } // WithShowUnitLevelSummary sets the showUnitLevelSummary flag for the report. // // When enabled, the summary of the report will include timings for each unit. func (r *Report) WithShowUnitLevelSummary() *Report { r.showUnitLevelSummary = true return r } // ErrPathMustBeAbsolute is returned when a report run path is not absolute. var ErrPathMustBeAbsolute = errors.New("report run path must be absolute") // NewRun creates a new run. // The path passed in must be an absolute path to ensure that the run can be uniquely identified. func NewRun(path string) (*Run, error) { if !filepath.IsAbs(path) { return nil, ErrPathMustBeAbsolute } return &Run{ Path: path, Started: time.Now(), }, nil } // ErrRunAlreadyExists is returned when a run already exists in the report. var ErrRunAlreadyExists = errors.New("run already exists") // AddRun adds a run to the report. // If the run already exists, it returns the ErrRunAlreadyExists error. func (r *Report) AddRun(l log.Logger, run *Run) error { r.mu.Lock() defer r.mu.Unlock() for _, existingRun := range r.Runs { if existingRun.Path == run.Path { return fmt.Errorf("%w: %s", ErrRunAlreadyExists, run.Path) } } l.Debugf("Adding report run %s", run.Path) r.Runs = append(r.Runs, run) return nil } // ErrRunNotFound is returned when a run is not found in the report. var ErrRunNotFound = errors.New("run not found in report") // GetRun returns a run from the report. // The path passed in must be an absolute path to ensure that the run can be uniquely identified. func (r *Report) GetRun(path string) (*Run, error) { r.mu.RLock() defer r.mu.RUnlock() if !filepath.IsAbs(path) { return nil, ErrPathMustBeAbsolute } for _, run := range r.Runs { if run.Path == path { return run, nil } } return nil, fmt.Errorf("%w: %s", ErrRunNotFound, path) } // EnsureRun tries to get a run from the report. // If the run does not exist, it creates a new run and adds it to the report, then returns the run. // This is useful when a run is being ended that might not have been started due to exclusion, etc. func (r *Report) EnsureRun(l log.Logger, path string, opts ...EndOption) (*Run, error) { run, err := r.GetRun(path) if err == nil { l.Debugf("Report run %s already exists, returning existing run", path) run.mu.Lock() defer run.mu.Unlock() for _, opt := range opts { opt(run) } return run, nil } if !errors.Is(err, ErrRunNotFound) { return run, err } l.Debugf("Report run %s not found, creating new run", path) run, err = NewRun(path) if err != nil { return run, err } for _, opt := range opts { opt(run) } if err = r.AddRun(l, run); err != nil { return run, err } return run, nil } // EndRun ends a run and adds it to the report. // If the run does not exist, it returns the ErrRunNotFound error. // By default, the run is assumed to have succeeded. To change this, pass WithResult to the function. // If the run has already ended from an early exit, it does nothing. func (r *Report) EndRun(l log.Logger, path string, endOptions ...EndOption) error { r.mu.Lock() defer r.mu.Unlock() if !filepath.IsAbs(path) { return ErrPathMustBeAbsolute } var run *Run for _, existingRun := range r.Runs { if existingRun.Path == path { run = existingRun break } } if run == nil { return fmt.Errorf("%w: %s", ErrRunNotFound, path) } // If the run has already ended from an early exit or excluded, we don't need to do anything. if !run.Ended.IsZero() && (run.Result == ResultEarlyExit || run.Result == ResultExcluded) { return nil } run.mu.Lock() defer run.mu.Unlock() run.Ended = time.Now() run.Result = ResultSucceeded for _, endOption := range endOptions { endOption(run) } l.Debugf("Ending report run %s with result %s", path, run.Result) return nil } func (r *Report) SortRuns() { slices.SortFunc(r.Runs, func(a, b *Run) int { return a.Started.Compare(b.Started) }) } // EndOption are optional configurations for ending a run. type EndOption func(*Run) // WithResult sets the result of a run. func WithResult(result Result) EndOption { return func(run *Run) { run.Result = result } } // WithReason sets the reason of a run. func WithReason(reason Reason) EndOption { return func(run *Run) { run.Reason = &reason } } // WithCauseRetryBlock sets the cause of a run to the name of a particular retry block. // // This function is a wrapper around withCause, just to make sure that authors always use consistent // reasons for causes. func WithCauseRetryBlock(name string) EndOption { return withCause(name) } // WithCauseIgnoreBlock sets the cause of a run to the name of a particular ignore block. // // This function is a wrapper around withCause, just to make sure that authors always use consistent // reasons for causes. func WithCauseIgnoreBlock(name string) EndOption { return withCause(name) } // WithCauseExcludeBlock sets the cause of a run to the name of a particular exclude block. // // This function is a wrapper around withCause, just to make sure that authors always use consistent // reasons for causes. func WithCauseExcludeBlock(name string) EndOption { return withCause(name) } // WithCauseAncestorExit sets the cause of a run to the name of a particular ancestor that exited. // // This function is a wrapper around withCause, just to make sure that authors always use consistent // reasons for causes. func WithCauseAncestorExit(name string) EndOption { return withCause(name) } // WithCauseRunError sets the cause of a run to the name of a particular run error. // // This function is a wrapper around withCause, just to make sure that authors always use consistent // reasons for causes. func WithCauseRunError(name string) EndOption { return withCause(name) } // WithDiscoveryWorkingDir sets the discovery working directory for a run. // This is used to compute relative paths for units discovered in worktrees. func WithDiscoveryWorkingDir(workingDir string) EndOption { return func(run *Run) { run.DiscoveryWorkingDir = workingDir } } // WithRef sets the worktree reference for a run. // This is typically a git commit, branch, or tag. func WithRef(ref string) EndOption { return func(run *Run) { run.Ref = ref } } // WithCmd sets the tofu/terraform command for a run. // This is the main tofu/terraform command being executed (e.g., plan, apply). func WithCmd(cmd string) EndOption { return func(run *Run) { run.Cmd = cmd } } // WithArgs sets the terraform CLI arguments for a run. func WithArgs(args []string) EndOption { return func(run *Run) { run.Args = args } } // withCause sets the cause of a run to the name of a particular cause. func withCause(name string) EndOption { return func(run *Run) { cause := Cause(name) run.Cause = &cause } } ================================================ FILE: internal/report/report_test.go ================================================ package report_test import ( "bytes" "encoding/csv" "encoding/json" "os" "path/filepath" "regexp" "slices" "strings" "testing" "testing/synctest" "time" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/xeipuuv/gojsonschema" ) func TestNewReport(t *testing.T) { t.Parallel() report := report.NewReport() assert.NotNil(t, report) assert.NotNil(t, report.Runs) assert.Empty(t, report.Runs) } func TestNewRun(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) path := filepath.Join(tmp, "test-run") run := newRun(t, path) assert.NotNil(t, run) assert.Equal(t, path, run.Path) assert.False(t, run.Started.IsZero()) assert.True(t, run.Ended.IsZero()) assert.Empty(t, run.Result) assert.Nil(t, run.Reason) assert.Nil(t, run.Cause) } func TestAddRun(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() path := filepath.Join(tmp, "test-run") r := report.NewReport() err := r.AddRun(l, newRun(t, path)) require.NoError(t, err) assert.Len(t, r.Runs, 1) err = r.AddRun(l, newRun(t, path)) require.Error(t, err) assert.ErrorIs(t, err, report.ErrRunAlreadyExists) } func TestGetRun(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() r := report.NewReport() run := newRun(t, filepath.Join(tmp, "test-run")) r.AddRun(l, run) tests := []struct { expectedErr error name string runName string }{ { name: "existing run", runName: filepath.Join(tmp, "test-run"), }, { name: "non-existent run", runName: filepath.Join(tmp, "non-existent"), expectedErr: report.ErrRunNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() run, err := r.GetRun(tt.runName) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) } else { require.NoError(t, err) assert.Equal(t, tt.runName, run.Path) } }) } } func TestEnsureRun(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { expectedErrIs error setupFunc func(*report.Report) *report.Run name string runName string existingRun bool expectError bool }{ { name: "creates new run when run does not exist", runName: filepath.Join(tmp, "new-run"), existingRun: false, expectError: false, }, { name: "returns existing run when it exists", runName: filepath.Join(tmp, "existing-run"), existingRun: true, expectError: false, setupFunc: func(r *report.Report) *report.Run { run := newRun(t, filepath.Join(tmp, "existing-run")) err := r.AddRun(l, run) require.NoError(t, err) return run }, }, { name: "returns error for invalid path", runName: "relative-path", existingRun: false, expectError: true, expectedErrIs: report.ErrPathMustBeAbsolute, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := report.NewReport() var existingRun *report.Run if tt.setupFunc != nil { existingRun = tt.setupFunc(r) } run, err := r.EnsureRun(l, tt.runName) if tt.expectError { require.Error(t, err) assert.Nil(t, run) if tt.expectedErrIs != nil { require.ErrorIs(t, err, tt.expectedErrIs) } } else { require.NoError(t, err) require.NotNil(t, run) assert.Equal(t, tt.runName, run.Path) assert.False(t, run.Started.IsZero()) if tt.existingRun { // Should return the same instance as the existing run assert.Equal(t, existingRun.Started, run.Started) } // Verify the run was added to the report retrievedRun, err := r.GetRun(tt.runName) require.NoError(t, err) assert.Equal(t, run, retrievedRun) // Verify that calling EnsureRun again returns the same run secondRun, err := r.EnsureRun(l, tt.runName) require.NoError(t, err) assert.Equal(t, run, secondRun) } }) } } func TestEndRun(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { wantReason *report.Reason wantCause *report.Cause name string runName string wantResult report.Result options []report.EndOption wantErr bool }{ { name: "successful end", runName: filepath.Join(tmp, "test-run"), options: []report.EndOption{}, wantErr: false, wantResult: report.ResultSucceeded, }, { name: "non-existent run", runName: filepath.Join(tmp, "non-existent"), options: []report.EndOption{}, wantErr: true, }, { name: "with result", runName: filepath.Join(tmp, "with-result"), options: []report.EndOption{report.WithResult(report.ResultFailed)}, wantErr: false, wantResult: report.ResultFailed, }, { name: "with reason", runName: filepath.Join(tmp, "with-reason"), options: []report.EndOption{report.WithReason(report.ReasonRunError)}, wantErr: false, wantResult: report.ResultSucceeded, wantReason: func() *report.Reason { r := report.ReasonRunError; return &r }(), }, { name: "with cause", runName: filepath.Join(tmp, "with-cause"), options: []report.EndOption{report.WithCauseRetryBlock("test-block")}, wantErr: false, wantResult: report.ResultSucceeded, wantCause: func() *report.Cause { c := report.Cause("test-block"); return &c }(), }, } r := report.NewReport() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if !tt.wantErr { run := newRun(t, tt.runName) r.AddRun(l, run) } err := r.EndRun(l, tt.runName, tt.options...) if tt.wantErr { require.Error(t, err) } else { require.NoError(t, err) run, err := r.GetRun(tt.runName) require.NoError(t, err) assert.Equal(t, tt.wantResult, run.Result) if tt.wantReason != nil { assert.NotNil(t, run.Reason) assert.Equal(t, *tt.wantReason, *run.Reason) } if tt.wantCause != nil { assert.NotNil(t, run.Cause) assert.Equal(t, *tt.wantCause, *run.Cause) } assert.False(t, run.Ended.IsZero()) } }) } } func TestEndRunAlreadyEnded(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { name string initialResult report.Result expectedResult report.Result secondResult report.Result initialOptions []report.EndOption secondOptions []report.EndOption }{ { name: "already ended with early exit is not overwritten", initialResult: report.ResultEarlyExit, secondResult: report.ResultSucceeded, expectedResult: report.ResultEarlyExit, }, { name: "already ended with excluded is not overwritten", initialResult: report.ResultExcluded, secondResult: report.ResultSucceeded, expectedResult: report.ResultExcluded, }, { name: "already ended with retry succeeded is overwritten", initialResult: report.ResultSucceeded, initialOptions: []report.EndOption{report.WithReason(report.ReasonRetrySucceeded)}, secondResult: report.ResultSucceeded, expectedResult: report.ResultSucceeded, }, { name: "already ended with retry failed is overwritten", initialResult: report.ResultSucceeded, initialOptions: []report.EndOption{report.WithReason(report.ReasonRetrySucceeded)}, secondResult: report.ResultFailed, expectedResult: report.ResultFailed, }, { name: "already ended with error ignored is overwritten", initialResult: report.ResultSucceeded, initialOptions: []report.EndOption{report.WithReason(report.ReasonErrorIgnored)}, secondResult: report.ResultSucceeded, expectedResult: report.ResultSucceeded, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() // Create a new report and run for each test case r := report.NewReport() runName := filepath.Join(tmp, tt.name) run := newRun(t, runName) r.AddRun(l, run) // Set up initial options with the initial result initialOptions := slices.Concat(tt.initialOptions, []report.EndOption{report.WithResult(tt.initialResult)}) // End the run with the initial state err := r.EndRun(l, runName, initialOptions...) require.NoError(t, err) // Set up second options with the second result secondOptions := slices.Concat(tt.secondOptions, []report.EndOption{report.WithResult(tt.secondResult)}) // Then try to end it again with a different state err = r.EndRun(l, runName, secondOptions...) require.NoError(t, err) // Verify that the result is the expected one endedRun, err := r.GetRun(runName) require.NoError(t, err) assert.Equal(t, tt.expectedResult, endedRun.Result) }) } } func TestSummarize(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { name string results []struct { name string result report.Result } wantTotalUnits int wantSucceeded int wantFailed int wantEarlyExits int wantExcluded int }{ { name: "empty report", results: []struct { name string result report.Result }{}, wantTotalUnits: 0, }, { name: "single successful run", results: []struct { name string result report.Result }{ {filepath.Join(tmp, "single-successful-run"), report.ResultSucceeded}, }, wantTotalUnits: 1, wantSucceeded: 1, }, { name: "mixed results", results: []struct { name string result report.Result }{ {filepath.Join(tmp, "successful-run"), report.ResultSucceeded}, {filepath.Join(tmp, "failed-run"), report.ResultFailed}, {filepath.Join(tmp, "early-exit-run"), report.ResultEarlyExit}, {filepath.Join(tmp, "excluded-run"), report.ResultExcluded}, }, wantTotalUnits: 4, wantSucceeded: 1, wantFailed: 1, wantEarlyExits: 1, wantExcluded: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := report.NewReport() for _, result := range tt.results { run := newRun(t, result.name) r.AddRun(l, run) r.EndRun(l, result.name, report.WithResult(result.result)) } summary := r.Summarize() assert.Equal(t, tt.wantTotalUnits, summary.TotalUnits()) assert.Equal(t, tt.wantSucceeded, summary.UnitsSucceeded) assert.Equal(t, tt.wantFailed, summary.UnitsFailed) assert.Equal(t, tt.wantEarlyExits, summary.EarlyExits) assert.Equal(t, tt.wantExcluded, summary.Excluded) }) } } func TestWriteCSV(t *testing.T) { t.Parallel() tests := []struct { name string setup func(l log.Logger, dir string, r *report.Report) expected [][]string }{ { name: "single successful run", setup: func(l log.Logger, dir string, r *report.Report) { run := newRun(t, filepath.Join(dir, "successful-run")) r.AddRun(l, run) r.EndRun(l, run.Path) }, expected: [][]string{ {"Name", "Started", "Ended", "Result", "Reason", "Cause", "Ref", "Cmd", "Args"}, {"successful-run", "", "", "succeeded", "", "", "", "", ""}, }, }, { name: "complex mixed results", setup: func(l log.Logger, dir string, r *report.Report) { // Add successful run successRun := newRun(t, filepath.Join(dir, "success-run")) r.AddRun(l, successRun) r.EndRun(l, successRun.Path) // Add failed run with reason failedRun := newRun(t, filepath.Join(dir, "failed-run")) r.AddRun(l, failedRun) r.EndRun(l, failedRun.Path, report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError)) // Add excluded run with cause excludedRun := newRun(t, filepath.Join(dir, "excluded-run")) r.AddRun(l, excludedRun) r.EndRun(l, excludedRun.Path, report.WithResult(report.ResultExcluded), report.WithCauseRetryBlock("test-block")) // Add early exit run with both reason and cause earlyExitRun := newRun(t, filepath.Join(dir, "early-exit-run")) r.AddRun(l, earlyExitRun) r.EndRun(l, earlyExitRun.Path, report.WithResult(report.ResultEarlyExit), report.WithReason(report.ReasonRunError), report.WithCauseRetryBlock("another-block"), ) }, expected: [][]string{ {"Name", "Started", "Ended", "Result", "Reason", "Cause", "Ref", "Cmd", "Args"}, {"success-run", "", "", "succeeded", "", "", "", "", ""}, {"failed-run", "", "", "failed", "run error", "", "", "", ""}, {"excluded-run", "", "", "excluded", "", "test-block", "", "", ""}, {"early-exit-run", "", "", "early exit", "run error", "another-block", "", "", ""}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() // Create a temporary file for the CSV csvFile := filepath.Join(tmp, "report.csv") file, err := os.Create(csvFile) require.NoError(t, err) defer file.Close() // Setup and write the report r := report.NewReport().WithWorkingDir(tmp) tt.setup(l, tmp, r) err = r.WriteCSV(file) require.NoError(t, err) // Close the file before reading file.Close() // Read the CSV file file, err = os.Open(csvFile) require.NoError(t, err) defer file.Close() reader := csv.NewReader(file) records, err := reader.ReadAll() require.NoError(t, err) // Verify the number of records require.Len(t, records, len(tt.expected)) // Verify each record for i, record := range records { expected := tt.expected[i] require.Len(t, record, len(expected), "Record %d has wrong number of fields", i) // For the header row, verify exact match if i == 0 { assert.Equal(t, expected, record) continue } // For data rows, verify fields individually assert.Equal(t, expected[0], record[0], "Name mismatch in record %d", i) // Skip timestamp verification for Started and Ended fields assert.Equal(t, expected[3], record[3], "Result mismatch in record %d", i) assert.Equal(t, expected[4], record[4], "Reason mismatch in record %d", i) assert.Equal(t, expected[5], record[5], "Cause mismatch in record %d", i) assert.Equal(t, expected[6], record[6], "Ref mismatch in record %d", i) assert.Equal(t, expected[7], record[7], "Cmd mismatch in record %d", i) assert.Equal(t, expected[8], record[8], "Args mismatch in record %d", i) // Verify that timestamps are in RFC3339 format if record[1] != "" { _, err := time.Parse(time.RFC3339, record[1]) require.NoError(t, err, "Started timestamp in record %d is not in RFC3339 format", i) } if record[2] != "" { _, err := time.Parse(time.RFC3339, record[2]) require.NoError(t, err, "Ended timestamp in record %d is not in RFC3339 format", i) } } }) } } func TestWriteJSON(t *testing.T) { t.Parallel() l := logger.CreateLogger() tests := []struct { name string setup func(l log.Logger, dir string, r *report.Report) expected string }{ { name: "single successful run", setup: func(l log.Logger, dir string, r *report.Report) { run := newRun(t, filepath.Join(dir, "successful-run")) r.AddRun(l, run) r.EndRun(l, run.Path) }, expected: `[ { "Name": "successful-run", "Started": "2024-03-21T10:00:00Z", "Ended": "2024-03-21T10:01:00Z", "Result": "succeeded" } ]`, }, { name: "complex mixed results", setup: func(l log.Logger, dir string, r *report.Report) { // Add successful run successRun := newRun(t, filepath.Join(dir, "success-run")) r.AddRun(l, successRun) r.EndRun(l, successRun.Path) // Add failed run with reason failedRun := newRun(t, filepath.Join(dir, "failed-run")) r.AddRun(l, failedRun) r.EndRun( l, failedRun.Path, report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError), ) // Add excluded run with cause retriedRun := newRun(t, filepath.Join(dir, "retried-run")) r.AddRun(l, retriedRun) r.EndRun( l, retriedRun.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonRetrySucceeded), ) // Add excluded run with cause excludedRun := newRun(t, filepath.Join(dir, "excluded-run")) r.AddRun(l, excludedRun) r.EndRun( l, excludedRun.Path, report.WithResult(report.ResultExcluded), report.WithReason(report.ReasonExcludeBlock), report.WithCauseExcludeBlock("test-block"), ) }, expected: `[ { "Name": "success-run", "Started": "2024-03-21T10:00:00Z", "Ended": "2024-03-21T10:01:00Z", "Result": "succeeded" }, { "Name": "failed-run", "Started": "2024-03-21T10:01:00Z", "Ended": "2024-03-21T10:02:00Z", "Result": "failed", "Reason": "run error" }, { "Name": "retried-run", "Started": "2024-03-21T10:03:00Z", "Ended": "2024-03-21T10:04:00Z", "Result": "succeeded", "Reason": "retry succeeded" }, { "Name": "excluded-run", "Started": "2024-03-21T10:02:00Z", "Ended": "2024-03-21T10:03:00Z", "Result": "excluded", "Reason": "exclude block", "Cause": "test-block" } ]`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) // Create a temporary file for the JSON jsonFile := filepath.Join(tmp, "report.json") file, err := os.Create(jsonFile) require.NoError(t, err) defer file.Close() // Setup and write the report r := report.NewReport().WithWorkingDir(tmp) tt.setup(l, tmp, r) err = r.WriteJSON(file) require.NoError(t, err) // Close the file before reading file.Close() // Read the JSON file file, err = os.Open(jsonFile) require.NoError(t, err) defer file.Close() // Read the actual output actualBytes, err := os.ReadFile(jsonFile) require.NoError(t, err) // Parse both expected and actual JSON to compare them var expectedJSON, actualJSON []map[string]any err = json.Unmarshal([]byte(tt.expected), &expectedJSON) require.NoError(t, err) err = json.Unmarshal(actualBytes, &actualJSON) require.NoError(t, err) // Verify the number of records require.Len(t, actualJSON, len(expectedJSON)) // Verify each record for i, actualRecord := range actualJSON { expectedRecord := expectedJSON[i] // Verify name assert.Equal(t, expectedRecord["Name"], actualRecord["Name"], "Name mismatch in record %d", i) // Verify result assert.Equal(t, expectedRecord["Result"], actualRecord["Result"], "Result mismatch in record %d", i) // Verify reason if present if expectedReason, ok := expectedRecord["Reason"]; ok { assert.Equal(t, expectedReason, actualRecord["Reason"], "Reason mismatch in record %d", i) } else { assert.NotContains(t, actualRecord, "Reason", "Unexpected reason in record %d", i) } // Verify cause if present if expectedCause, ok := expectedRecord["Cause"]; ok { assert.Equal(t, expectedCause, actualRecord["Cause"], "Cause mismatch in record %d", i) } else { assert.NotContains(t, actualRecord, "Cause", "Unexpected cause in record %d", i) } // Verify timestamps are in RFC3339 format if started, ok := actualRecord["Started"].(string); ok { _, err := time.Parse(time.RFC3339, started) require.NoError(t, err, "Started timestamp in record %d is not in RFC3339 format", i) } if ended, ok := actualRecord["Ended"].(string); ok { _, err := time.Parse(time.RFC3339, ended) require.NoError(t, err, "Ended timestamp in record %d is not in RFC3339 format", i) } } }) } } const ExpectedSchema = `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://docs.terragrunt.com/schemas/run/report/v4/schema.json", "items": { "properties": { "Started": { "type": "string", "format": "date-time" }, "Ended": { "type": "string", "format": "date-time" }, "Reason": { "type": "string", "enum": [ "retry succeeded", "error ignored", "run error", "exclude block", "ancestor error" ] }, "Cause": { "type": "string" }, "Name": { "type": "string" }, "Result": { "type": "string", "enum": [ "succeeded", "failed", "early exit", "excluded" ] }, "Ref": { "type": "string" }, "Cmd": { "type": "string" }, "Args": { "items": { "type": "string" }, "type": "array" } }, "additionalProperties": false, "type": "object", "required": [ "Started", "Ended", "Name", "Result" ], "title": "Terragrunt Run Report Schema", "description": "Schema for Terragrunt run report" }, "type": "array", "title": "Terragrunt Run Report Schema", "description": "Array of Terragrunt runs" } ` func TestWriteSchema(t *testing.T) { t.Parallel() // Create a buffer to write the schema to var buf bytes.Buffer // Write the schema err := report.WriteSchema(&buf) require.NoError(t, err) // Assert the contents of the schema assert.JSONEq(t, ExpectedSchema, buf.String()) // Parse the schema var schema map[string]any err = json.Unmarshal(buf.Bytes(), &schema) require.NoError(t, err) // Verify the schema structure assert.Equal(t, "array", schema["type"]) assert.Equal(t, "Array of Terragrunt runs", schema["description"]) assert.Equal(t, "Terragrunt Run Report Schema", schema["title"]) // Verify the items schema items, ok := schema["items"].(map[string]any) require.True(t, ok) // Verify the properties properties, ok := items["properties"].(map[string]any) require.True(t, ok) // Verify required fields required, ok := items["required"].([]any) require.True(t, ok) assert.Contains(t, required, "Name") assert.Contains(t, required, "Started") assert.Contains(t, required, "Ended") assert.Contains(t, required, "Result") // Verify field types assert.Equal(t, "string", properties["Name"].(map[string]any)["type"]) assert.Equal(t, "string", properties["Result"].(map[string]any)["type"]) assert.Equal(t, "string", properties["Started"].(map[string]any)["type"]) assert.Equal(t, "string", properties["Ended"].(map[string]any)["type"]) // Verify optional fields reason, ok := properties["Reason"].(map[string]any) require.True(t, ok) assert.Equal(t, "string", reason["type"]) cause, ok := properties["Cause"].(map[string]any) require.True(t, ok) assert.Equal(t, "string", cause["type"]) } func TestExpectedSchemaIsInDocs(t *testing.T) { t.Parallel() tests := []struct { name string file string }{ { name: "starlight", file: filepath.Join( "..", "..", "docs", "public", "schemas", "run", "report", "v4", "schema.json", ), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() schema, err := os.ReadFile(tt.file) require.NoError(t, err) assert.JSONEq(t, ExpectedSchema, string(schema)) }) } } func TestWriteSummary(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { name string setup func(l log.Logger, r *report.Report) expected string }{ { name: "single successful run", setup: func(l log.Logger, r *report.Report) { run := newRun(t, filepath.Join(tmp, "successful-run")) r.AddRun(l, run) r.EndRun(l, run.Path) }, expected: ` ❯❯ Run Summary 1 units x ──────────────────────────── Succeeded 1 `, }, { name: "complex mixed results", setup: func(l log.Logger, r *report.Report) { // Add successful runs firstSuccessfulRun := newRun(t, filepath.Join(tmp, "first-successful-run")) r.AddRun(l, firstSuccessfulRun) r.EndRun(l, firstSuccessfulRun.Path) secondSuccessfulRun := newRun(t, filepath.Join(tmp, "second-successful-run")) r.AddRun(l, secondSuccessfulRun) r.EndRun(l, secondSuccessfulRun.Path) // Add failed runs firstFailedRun := newRun(t, filepath.Join(tmp, "first-failed-run")) r.AddRun(l, firstFailedRun) r.EndRun(l, firstFailedRun.Path, report.WithResult(report.ResultFailed)) secondFailedRun := newRun(t, filepath.Join(tmp, "second-failed-run")) r.AddRun(l, secondFailedRun) r.EndRun(l, secondFailedRun.Path, report.WithResult(report.ResultFailed)) // Add excluded runs firstExcludedRun := newRun(t, filepath.Join(tmp, "first-excluded-run")) r.AddRun(l, firstExcludedRun) r.EndRun(l, firstExcludedRun.Path, report.WithResult(report.ResultExcluded)) secondExcludedRun := newRun(t, filepath.Join(tmp, "second-excluded-run")) r.AddRun(l, secondExcludedRun) r.EndRun(l, secondExcludedRun.Path, report.WithResult(report.ResultExcluded)) // Add early exit runs firstEarlyExitRun := newRun(t, filepath.Join(tmp, "first-early-exit-run")) r.AddRun(l, firstEarlyExitRun) r.EndRun(l, firstEarlyExitRun.Path, report.WithResult(report.ResultEarlyExit)) secondEarlyExitRun := newRun(t, filepath.Join(tmp, "second-early-exit-run")) r.AddRun(l, secondEarlyExitRun) r.EndRun(l, secondEarlyExitRun.Path, report.WithResult(report.ResultEarlyExit)) }, expected: ` ❯❯ Run Summary 8 units x ──────────────────────────── Succeeded 2 Failed 2 Early Exits 2 Excluded 2 `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := report.NewReport().WithDisableColor() tt.setup(l, r) var buf bytes.Buffer err := r.WriteSummary(&buf) require.NoError(t, err) output := buf.String() // Replace the duration in the header with x // Pattern matches: "❯❯ Run Summary 8 units 42µs" -> "❯❯ Run Summary 8 units x" re := regexp.MustCompile(`(❯❯ Run Summary\s+\d+\s+units\s+)[^\n]+`) output = re.ReplaceAllString(output, "${1}x") expected := strings.TrimSpace(tt.expected) assert.Equal(t, expected, strings.TrimSpace(output)) }) } } func TestSchemaIsValid(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() // Create a new report with working directory r := report.NewReport().WithWorkingDir(tmp) // Add a simple run that succeeds simpleRun := newRun(t, filepath.Join(tmp, "simple-run")) r.AddRun(l, simpleRun) r.EndRun(l, simpleRun.Path, report.WithResult(report.ResultSucceeded), ) // Add a complex run that tests all possible fields and states complexRun := newRun(t, filepath.Join(tmp, "complex-run")) r.AddRun(l, complexRun) r.EndRun(l, complexRun.Path, report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError), report.WithCauseAncestorExit("some-error"), ) // Create an excluded run with exclude block excludedRun := newRun(t, filepath.Join(tmp, "excluded-run")) r.AddRun(l, excludedRun) r.EndRun(l, excludedRun.Path, report.WithResult(report.ResultExcluded), report.WithReason(report.ReasonExcludeBlock), report.WithCauseExcludeBlock("test-block"), ) // Create a retry run that succeeded retryRun := newRun(t, filepath.Join(tmp, "retry-run")) r.AddRun(l, retryRun) r.EndRun(l, retryRun.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonRetrySucceeded), report.WithCauseRetryBlock("retry-block"), ) // Create an early exit run earlyExitRun := newRun(t, filepath.Join(tmp, "early-exit-run")) r.AddRun(l, earlyExitRun) r.EndRun(l, earlyExitRun.Path, report.WithResult(report.ResultEarlyExit), report.WithReason(report.ReasonAncestorError), report.WithCauseAncestorExit("parent-unit"), ) // Create a run with ignored error ignoredRun := newRun(t, filepath.Join(tmp, "ignored-run")) r.AddRun(l, ignoredRun) r.EndRun(l, ignoredRun.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonErrorIgnored), report.WithCauseIgnoreBlock("ignore-block"), ) // Write the report to a JSON file reportFile := filepath.Join(tmp, "report.json") file, err := os.Create(reportFile) require.NoError(t, err) defer file.Close() err = r.WriteJSON(file) require.NoError(t, err) file.Close() // Write the schema to a file schemaFile := filepath.Join(tmp, "schema.json") file, err = os.Create(schemaFile) require.NoError(t, err) defer file.Close() err = report.WriteSchema(file) require.NoError(t, err) file.Close() // Read the schema and report files schemaBytes, err := os.ReadFile(schemaFile) require.NoError(t, err) reportBytes, err := os.ReadFile(reportFile) require.NoError(t, err) // Create a new schema loader schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) documentLoader := gojsonschema.NewBytesLoader(reportBytes) // Validate the report against the schema result, err := gojsonschema.Validate(schemaLoader, documentLoader) require.NoError(t, err) // Check if the validation was successful assert.True(t, result.Valid(), "JSON report does not match schema: %v", result.Errors()) // Additional validation of the report content var reportData []report.JSONRun err = json.Unmarshal(reportBytes, &reportData) require.NoError(t, err) // Verify we have all the expected runs require.Len(t, reportData, 6) // Helper function to find a run by name findRun := func(name string) *report.JSONRun { for _, run := range reportData { if run.Name == name { return &run } } return nil } // Verify simple run simple := findRun("simple-run") require.NotNil(t, simple) assert.Equal(t, "succeeded", simple.Result) assert.Nil(t, simple.Reason) assert.Nil(t, simple.Cause) assert.False(t, simple.Started.IsZero()) assert.False(t, simple.Ended.IsZero()) // Verify complex run complex := findRun("complex-run") require.NotNil(t, complex) assert.Equal(t, "failed", complex.Result) assert.Equal(t, "run error", *complex.Reason) assert.Equal(t, "some-error", *complex.Cause) assert.False(t, complex.Started.IsZero()) assert.False(t, complex.Ended.IsZero()) // Verify excluded run excluded := findRun("excluded-run") require.NotNil(t, excluded) assert.Equal(t, "excluded", excluded.Result) assert.Equal(t, "exclude block", *excluded.Reason) assert.Equal(t, "test-block", *excluded.Cause) // Verify retry run retry := findRun("retry-run") require.NotNil(t, retry) assert.Equal(t, "succeeded", retry.Result) assert.Equal(t, "retry succeeded", *retry.Reason) assert.Equal(t, "retry-block", *retry.Cause) // Verify early exit run earlyExit := findRun("early-exit-run") require.NotNil(t, earlyExit) assert.Equal(t, "early exit", earlyExit.Result) assert.Equal(t, "ancestor error", *earlyExit.Reason) assert.Equal(t, "parent-unit", *earlyExit.Cause) // Verify ignored run ignored := findRun("ignored-run") require.NotNil(t, ignored) assert.Equal(t, "succeeded", ignored.Result) assert.Equal(t, "error ignored", *ignored.Reason) assert.Equal(t, "ignore-block", *ignored.Cause) } func TestWriteUnitLevelSummary(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) l := logger.CreateLogger() tests := []struct { name string setup func(l log.Logger, r *report.Report) expected string }{ { name: "empty runs", setup: func(l log.Logger, r *report.Report) { // No runs added }, expected: ``, }, { name: "single run", setup: func(l log.Logger, r *report.Report) { run := newRun(t, filepath.Join(tmp, "single-run")) r.AddRun(l, run) r.EndRun(l, run.Path) }, expected: ` ❯❯ Run Summary 1 units x ──────────────────────────── Succeeded (1) single-run ....... x `, }, { name: "multiple runs sorted by duration", setup: func(l log.Logger, r *report.Report) { // Use syntest.Test so that we can artificially manipulate the clock for duration testing. synctest.Test(t, func(t *testing.T) { t.Helper() longRun := newRun(t, filepath.Join(tmp, "long-run")) r.AddRun(l, longRun) time.Sleep(1 * time.Second) mediumRun := newRun(t, filepath.Join(tmp, "medium-run")) r.AddRun(l, mediumRun) time.Sleep(1 * time.Second) shortRun := newRun(t, filepath.Join(tmp, "short-run")) r.AddRun(l, shortRun) time.Sleep(1 * time.Second) r.EndRun(l, shortRun.Path) time.Sleep(1 * time.Second) r.EndRun(l, mediumRun.Path) time.Sleep(1 * time.Second) r.EndRun(l, longRun.Path) }) }, expected: ` ❯❯ Run Summary 3 units x ──────────────────────────── Succeeded (3) long-run ......... x medium-run ....... x short-run ........ x `, }, { name: "mixed results grouped by category", setup: func(l log.Logger, r *report.Report) { // Use syntest.Test so that we can artificially manipulate the clock for duration testing. synctest.Test(t, func(t *testing.T) { t.Helper() successRun1 := newRun(t, filepath.Join(tmp, "success-1")) time.Sleep(1 * time.Second) successRun2 := newRun(t, filepath.Join(tmp, "success-2")) time.Sleep(1 * time.Second) failRun := newRun(t, filepath.Join(tmp, "fail-run")) time.Sleep(1 * time.Second) excludedRun := newRun(t, filepath.Join(tmp, "excluded-run")) r.AddRun(l, successRun1) time.Sleep(1 * time.Second) r.AddRun(l, successRun2) time.Sleep(1 * time.Second) r.AddRun(l, failRun) time.Sleep(1 * time.Second) r.AddRun(l, excludedRun) time.Sleep(1 * time.Second) r.EndRun(l, successRun1.Path) time.Sleep(1 * time.Second) r.EndRun(l, successRun2.Path) time.Sleep(1 * time.Second) r.EndRun(l, failRun.Path, report.WithResult(report.ResultFailed)) time.Sleep(1 * time.Second) r.EndRun(l, excludedRun.Path, report.WithResult(report.ResultExcluded)) }) }, expected: ` ❯❯ Run Summary 4 units x ──────────────────────────── Succeeded (2) success-1 ........ x success-2 ........ x Failed (1) fail-run ......... x Excluded (1) excluded-run ..... x `, }, { name: "very short unit names", setup: func(l log.Logger, r *report.Report) { // Use syntest.Test so that we can artificially manipulate the clock for duration testing. synctest.Test(t, func(t *testing.T) { t.Helper() a := newRun(t, filepath.Join(tmp, "a")) time.Sleep(1 * time.Second) b := newRun(t, filepath.Join(tmp, "b")) time.Sleep(1 * time.Second) c := newRun(t, filepath.Join(tmp, "c")) r.AddRun(l, a) time.Sleep(1 * time.Second) r.AddRun(l, b) time.Sleep(1 * time.Second) r.AddRun(l, c) time.Sleep(1 * time.Second) r.EndRun(l, a.Path) time.Sleep(1 * time.Second) r.EndRun(l, b.Path) time.Sleep(1 * time.Second) r.EndRun(l, c.Path) }) }, expected: ` ❯❯ Run Summary 3 units x ──────────────────────────── Succeeded (3) a ................ x b ................ x c ................ x `, }, { name: "very long unit names", setup: func(l log.Logger, r *report.Report) { // Use syntest.Test so that we can artificially manipulate the clock for duration testing. synctest.Test(t, func(t *testing.T) { t.Helper() longName1 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-1")) time.Sleep(1 * time.Second) longName2 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-2")) time.Sleep(1 * time.Second) longName3 := newRun(t, filepath.Join(tmp, "this-is-a-very-long-name-3")) time.Sleep(1 * time.Second) r.AddRun(l, longName1) time.Sleep(1 * time.Second) r.AddRun(l, longName2) time.Sleep(1 * time.Second) r.AddRun(l, longName3) time.Sleep(1 * time.Second) r.EndRun(l, longName1.Path) time.Sleep(1 * time.Second) r.EndRun(l, longName2.Path) time.Sleep(1 * time.Second) r.EndRun(l, longName3.Path) }) }, expected: ` ❯❯ Run Summary 3 units x ─────────────────────────────────── Succeeded (3) this-is-a-very-long-name-1 x this-is-a-very-long-name-2 x this-is-a-very-long-name-3 x `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := report.NewReport(). WithDisableColor(). WithShowUnitLevelSummary(). WithWorkingDir(tmp) tt.setup(l, r) var buf bytes.Buffer err := r.WriteSummary(&buf) require.NoError(t, err) // Replace the duration with x since we can't control the actual duration in tests output := buf.String() // Replace the header duration with x re := regexp.MustCompile(`❯❯ Run Summary (\d+) units(\s+)(\d+.+)`) output = re.ReplaceAllString(output, "❯❯ Run Summary ${1} units${2}x") // Replace all the unit level summaries re = regexp.MustCompile(`([ ]{6})([^ ]+)( )([^ ]*)( )(\d+.+)`) output = re.ReplaceAllString(output, "${1}${2}${3}${4}${5}x") expected := strings.TrimSpace(tt.expected) assert.Equal(t, expected, strings.TrimSpace(output)) }) } } // TestWriteJSONWithDiscoveryWorkingDir verifies that when a run has a DiscoveryWorkingDir set, // the report writer uses it instead of the report's workingDir for path computation. // This is critical for worktree scenarios where units are discovered in temporary worktree directories. func TestWriteJSONWithDiscoveryWorkingDir(t *testing.T) { t.Parallel() l := logger.CreateLogger() // Simulate a worktree scenario: // - Original working dir: /original/repo // - Worktree path: /tmp/terragrunt-worktree-xxx/original/repo // - Unit path in worktree: /tmp/terragrunt-worktree-xxx/original/repo/module/unit // Create temp directories originalRepoDir := helpers.TmpDirWOSymlinks(t) worktreeDir := helpers.TmpDirWOSymlinks(t) // Create the "unit" path in the worktree unitPath := filepath.Join(worktreeDir, "module", "unit") err := os.MkdirAll(unitPath, 0755) require.NoError(t, err) // Create a report with the original repo as working dir (simulating non-worktree scenario) r := report.NewReport().WithWorkingDir(originalRepoDir) // Add a run that was discovered in the worktree // Set the DiscoveryWorkingDir to the worktree path run := newRun(t, unitPath) r.AddRun(l, run) // Ensure the run and set the DiscoveryWorkingDir r.EnsureRun(l, unitPath, report.WithDiscoveryWorkingDir(worktreeDir)) // End the run r.EndRun(l, unitPath, report.WithResult(report.ResultSucceeded)) // Write JSON and verify the name is relative to DiscoveryWorkingDir, not report.workingDir var buf bytes.Buffer err = r.WriteJSON(&buf) require.NoError(t, err) // Parse the JSON var runs []report.JSONRun err = json.Unmarshal(buf.Bytes(), &runs) require.NoError(t, err) require.Len(t, runs, 1) // The name should be relative to the worktree dir, not the original repo dir // Without the fix, this would be the full absolute path since unitPath doesn't start with originalRepoDir assert.Equal(t, "module/unit", runs[0].Name, "Run name should be relative to DiscoveryWorkingDir, not report.workingDir") } // TestWriteCSVWithDiscoveryWorkingDir verifies that CSV output also uses DiscoveryWorkingDir. func TestWriteCSVWithDiscoveryWorkingDir(t *testing.T) { t.Parallel() l := logger.CreateLogger() // Create temp directories (simulating worktree scenario) originalRepoDir := helpers.TmpDirWOSymlinks(t) worktreeDir := helpers.TmpDirWOSymlinks(t) // Create the "unit" path in the worktree unitPath := filepath.Join(worktreeDir, "module", "unit") err := os.MkdirAll(unitPath, 0755) require.NoError(t, err) // Create a report with the original repo as working dir r := report.NewReport().WithWorkingDir(originalRepoDir) // Add a run with DiscoveryWorkingDir set to worktree path run := newRun(t, unitPath) r.AddRun(l, run) r.EnsureRun(l, unitPath, report.WithDiscoveryWorkingDir(worktreeDir)) r.EndRun(l, unitPath, report.WithResult(report.ResultSucceeded)) // Write CSV var buf bytes.Buffer err = r.WriteCSV(&buf) require.NoError(t, err) // Parse the CSV reader := csv.NewReader(&buf) records, err := reader.ReadAll() require.NoError(t, err) require.Len(t, records, 2) // header + 1 data row // The name (first column) should be relative to worktree dir assert.Equal(t, "module/unit", records[1][0], "Run name should be relative to DiscoveryWorkingDir, not report.workingDir") } // TestParseJSONRuns verifies that JSON report data can be parsed from bytes. func TestParseJSONRuns(t *testing.T) { t.Parallel() tests := []struct { name string input string expected report.JSONRuns expectError bool }{ { name: "empty array", input: "[]", expected: report.JSONRuns{}, }, { name: "single run", input: `[{ "Name": "module/unit", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded" }]`, expected: report.JSONRuns{ { Name: "module/unit", Result: "succeeded", }, }, }, { name: "multiple runs with all fields", input: `[ { "Name": "unit-a", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded" }, { "Name": "unit-b", "Started": "2024-01-01T10:01:00Z", "Ended": "2024-01-01T10:02:00Z", "Result": "failed", "Reason": "run error", "Cause": "some error" } ]`, expected: report.JSONRuns{ {Name: "unit-a", Result: "succeeded"}, {Name: "unit-b", Result: "failed"}, }, }, { name: "invalid json", input: "not valid json", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() runs, err := report.ParseJSONRuns([]byte(tt.input)) if tt.expectError { require.Error(t, err) return } require.NoError(t, err) require.Len(t, runs, len(tt.expected)) for i, expected := range tt.expected { assert.Equal(t, expected.Name, runs[i].Name) assert.Equal(t, expected.Result, runs[i].Result) } }) } } // TestParseJSONRunsFromFile verifies that JSON report data can be parsed from a file. func TestParseJSONRunsFromFile(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) t.Run("valid file", func(t *testing.T) { t.Parallel() reportFile := filepath.Join(tmp, "valid-report.json") content := `[{"Name": "test-unit", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded"}]` err := os.WriteFile(reportFile, []byte(content), 0644) require.NoError(t, err) runs, err := report.ParseJSONRunsFromFile(reportFile) require.NoError(t, err) require.Len(t, runs, 1) assert.Equal(t, "test-unit", runs[0].Name) }) t.Run("non-existent file", func(t *testing.T) { t.Parallel() _, err := report.ParseJSONRunsFromFile(filepath.Join(tmp, "does-not-exist.json")) require.Error(t, err) }) } // TestJSONRunsFindByName verifies that runs can be found by name. func TestJSONRunsFindByName(t *testing.T) { t.Parallel() runs := report.JSONRuns{ {Name: "unit-a", Result: "succeeded"}, {Name: "module/unit-b", Result: "failed"}, {Name: "nested/path/unit-c", Result: "excluded"}, } tests := []struct { expected *report.JSONRun name string search string }{ { name: "find first unit", search: "unit-a", expected: &report.JSONRun{Name: "unit-a", Result: "succeeded"}, }, { name: "find nested path", search: "module/unit-b", expected: &report.JSONRun{Name: "module/unit-b", Result: "failed"}, }, { name: "find deeply nested", search: "nested/path/unit-c", expected: &report.JSONRun{Name: "nested/path/unit-c", Result: "excluded"}, }, { name: "not found", search: "does-not-exist", expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := runs.FindByName(tt.search) if tt.expected == nil { assert.Nil(t, result) } else { require.NotNil(t, result) assert.Equal(t, tt.expected.Name, result.Name) assert.Equal(t, tt.expected.Result, result.Result) } }) } } // TestJSONRunsNames verifies that run names can be extracted from a slice of runs. func TestJSONRunsNames(t *testing.T) { t.Parallel() tests := []struct { name string runs report.JSONRuns expected []string }{ { name: "empty slice", runs: report.JSONRuns{}, expected: []string{}, }, { name: "multiple runs", runs: report.JSONRuns{ {Name: "unit-a"}, {Name: "module/unit-b"}, {Name: "nested/path/unit-c"}, }, expected: []string{"unit-a", "module/unit-b", "nested/path/unit-c"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() names := tt.runs.Names() assert.Equal(t, tt.expected, names) }) } } // TestParseCSVRuns verifies that CSV report data can be parsed from bytes. func TestParseCSVRuns(t *testing.T) { t.Parallel() tests := []struct { name string input string expected report.CSVRuns expectError bool }{ { name: "header only", input: "Name,Started,Ended,Result,Reason,Cause,Ref,Cmd,Args\n", expected: report.CSVRuns{}, }, { name: "single run", input: "Name,Started,Ended,Result,Reason,Cause,Ref,Cmd,Args\nmodule/unit,2024-01-01T10:00:00Z,2024-01-01T10:01:00Z,succeeded,,,,,\n", expected: report.CSVRuns{{Name: "module/unit", Started: "2024-01-01T10:00:00Z", Ended: "2024-01-01T10:01:00Z", Result: "succeeded"}}, }, { name: "multiple runs with all fields", input: `Name,Started,Ended,Result,Reason,Cause,Ref,Cmd,Args unit-a,2024-01-01T10:00:00Z,2024-01-01T10:01:00Z,succeeded,,,HEAD~1,plan,-out=plan.tfplan|-var=foo=bar unit-b,2024-01-01T10:01:00Z,2024-01-01T10:02:00Z,failed,run error,some error,main,apply, `, expected: report.CSVRuns{ {Name: "unit-a", Started: "2024-01-01T10:00:00Z", Ended: "2024-01-01T10:01:00Z", Result: "succeeded", Ref: "HEAD~1", Cmd: "plan", Args: "-out=plan.tfplan|-var=foo=bar"}, {Name: "unit-b", Started: "2024-01-01T10:01:00Z", Ended: "2024-01-01T10:02:00Z", Result: "failed", Reason: "run error", Cause: "some error", Ref: "main", Cmd: "apply"}, }, }, { name: "invalid csv - missing fields", input: "Name,Started,Ended,Result,Reason,Cause,Ref,Cmd,Args\nunit-a,2024-01-01T10:00:00Z\n", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() runs, err := report.ParseCSVRuns([]byte(tt.input)) if tt.expectError { require.Error(t, err) return } require.NoError(t, err) require.Len(t, runs, len(tt.expected)) for i, expected := range tt.expected { assert.Equal(t, expected.Name, runs[i].Name) assert.Equal(t, expected.Result, runs[i].Result) assert.Equal(t, expected.Reason, runs[i].Reason) assert.Equal(t, expected.Cause, runs[i].Cause) assert.Equal(t, expected.Ref, runs[i].Ref) assert.Equal(t, expected.Cmd, runs[i].Cmd) assert.Equal(t, expected.Args, runs[i].Args) } }) } } // TestParseCSVRunsFromFile verifies that CSV report data can be parsed from a file. func TestParseCSVRunsFromFile(t *testing.T) { t.Parallel() tmp := helpers.TmpDirWOSymlinks(t) t.Run("valid file", func(t *testing.T) { t.Parallel() reportFile := filepath.Join(tmp, "valid-report.csv") content := "Name,Started,Ended,Result,Reason,Cause,Ref,Cmd,Args\ntest-unit,2024-01-01T10:00:00Z,2024-01-01T10:01:00Z,succeeded,,,,,\n" err := os.WriteFile(reportFile, []byte(content), 0644) require.NoError(t, err) runs, err := report.ParseCSVRunsFromFile(reportFile) require.NoError(t, err) require.Len(t, runs, 1) assert.Equal(t, "test-unit", runs[0].Name) }) t.Run("non-existent file", func(t *testing.T) { t.Parallel() _, err := report.ParseCSVRunsFromFile(filepath.Join(tmp, "does-not-exist.csv")) require.Error(t, err) }) } // TestCSVRunsFindByName verifies that CSV runs can be found by name. func TestCSVRunsFindByName(t *testing.T) { t.Parallel() runs := report.CSVRuns{ {Name: "unit-a", Result: "succeeded"}, {Name: "module/unit-b", Result: "failed"}, {Name: "nested/path/unit-c", Result: "excluded"}, } tests := []struct { expected *report.CSVRun name string search string }{ { name: "find first unit", search: "unit-a", expected: &report.CSVRun{Name: "unit-a", Result: "succeeded"}, }, { name: "find nested path", search: "module/unit-b", expected: &report.CSVRun{Name: "module/unit-b", Result: "failed"}, }, { name: "not found", search: "does-not-exist", expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := runs.FindByName(tt.search) if tt.expected == nil { assert.Nil(t, result) } else { require.NotNil(t, result) assert.Equal(t, tt.expected.Name, result.Name) assert.Equal(t, tt.expected.Result, result.Result) } }) } } // TestCSVRunsNames verifies that run names can be extracted from a slice of CSV runs. func TestCSVRunsNames(t *testing.T) { t.Parallel() tests := []struct { name string runs report.CSVRuns expected []string }{ { name: "empty slice", runs: report.CSVRuns{}, expected: []string{}, }, { name: "multiple runs", runs: report.CSVRuns{ {Name: "unit-a"}, {Name: "module/unit-b"}, {Name: "nested/path/unit-c"}, }, expected: []string{"unit-a", "module/unit-b", "nested/path/unit-c"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() names := tt.runs.Names() assert.Equal(t, tt.expected, names) }) } } // TestParseJSONRunsFromFileValidation verifies that JSON report validation works correctly // when parsing files. Validation is performed as the first step in parsing. func TestParseJSONRunsFromFileValidation(t *testing.T) { t.Parallel() tests := []struct { name string input string expectError bool }{ { name: "valid empty report", input: "[]", expectError: false, }, { name: "valid single run", input: `[{ "Name": "module/unit", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded" }]`, expectError: false, }, { name: "valid run with all fields", input: `[{ "Name": "module/unit", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "failed", "Reason": "run error", "Cause": "some error" }]`, expectError: false, }, { name: "valid multiple runs", input: `[ {"Name": "unit-a", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded"}, {"Name": "unit-b", "Started": "2024-01-01T10:01:00Z", "Ended": "2024-01-01T10:02:00Z", "Result": "failed", "Reason": "run error"} ]`, expectError: false, }, { name: "invalid - not an array", input: `{"Name": "unit", "Result": "succeeded"}`, expectError: true, }, { name: "invalid - missing required field Name", input: `[{"Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z", "Result": "succeeded"}]`, expectError: true, }, { name: "invalid - missing required field Result", input: `[{"Name": "unit", "Started": "2024-01-01T10:00:00Z", "Ended": "2024-01-01T10:01:00Z"}]`, expectError: true, }, { name: "invalid json", input: "not valid json", expectError: true, }, } tmp := helpers.TmpDirWOSymlinks(t) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() reportFile := filepath.Join(tmp, strings.ReplaceAll(tt.name, " ", "-")+".json") err := os.WriteFile(reportFile, []byte(tt.input), 0644) require.NoError(t, err) _, err = report.ParseJSONRunsFromFile(reportFile) if tt.expectError { require.Error(t, err) } else { require.NoError(t, err) } }) } t.Run("schema validation error details", func(t *testing.T) { t.Parallel() reportFile := filepath.Join(tmp, "schema-error-details.json") content := `[{"Name": "test-unit"}]` // missing required fields err := os.WriteFile(reportFile, []byte(content), 0644) require.NoError(t, err) _, err = report.ParseJSONRunsFromFile(reportFile) require.Error(t, err) var schemaErr *report.SchemaValidationError require.ErrorAs(t, err, &schemaErr) assert.NotEmpty(t, schemaErr.Errors) }) } // TestSchemaValidationError verifies the error type works correctly. func TestSchemaValidationError(t *testing.T) { t.Parallel() err := &report.SchemaValidationError{ Errors: []string{"error 1", "error 2"}, } assert.Contains(t, err.Error(), "2 error(s)") assert.Contains(t, err.Error(), "error 1") assert.Contains(t, err.Error(), "error 2") } // newRun creates a new run, and asserts that it doesn't error. func newRun(t *testing.T, name string) *report.Run { t.Helper() run, err := report.NewRun(name) require.NoError(t, err) return run } ================================================ FILE: internal/report/summary.go ================================================ package report import ( "fmt" "io" "os" "regexp" "slices" "strconv" "strings" "time" ) // Summary formats data from a report for output as a summary. type Summary struct { firstRunStart *time.Time lastRunEnd *time.Time padder string workingDir string runs []*Run UnitsSucceeded int UnitsFailed int EarlyExits int Excluded int shouldColor bool showUnitLevelSummary bool } // Summarize returns a summary of the report. func (r *Report) Summarize() *Summary { summary := &Summary{ workingDir: r.workingDir, shouldColor: r.shouldColor, showUnitLevelSummary: r.showUnitLevelSummary, padder: ".", runs: r.Runs, } if len(r.Runs) == 0 { return summary } for _, run := range r.Runs { summary.Update(run) } return summary } func (s *Summary) TotalUnits() int { return len(s.runs) } func (s *Summary) Update(run *Run) { run.mu.RLock() defer run.mu.RUnlock() switch run.Result { case ResultSucceeded: s.UnitsSucceeded++ case ResultFailed: s.UnitsFailed++ case ResultEarlyExit: s.EarlyExits++ case ResultExcluded: s.Excluded++ } if s.firstRunStart == nil || run.Started.Before(*s.firstRunStart) { s.firstRunStart = &run.Started } if !run.Ended.IsZero() && (s.lastRunEnd == nil || run.Ended.After(*s.lastRunEnd)) { s.lastRunEnd = &run.Ended } } // TotalDuration returns the total duration of all runs in the report. func (s *Summary) TotalDuration() time.Duration { if s.firstRunStart == nil || s.lastRunEnd == nil { return 0 } return s.lastRunEnd.Sub(*s.firstRunStart) } // TotalDurationString returns the total duration of all runs in the report as a string. // It returns the duration in the format that is easy to understand by humans. func (s *Summary) TotalDurationString(colorizer *Colorizer) string { duration := s.TotalDuration() return colorizer.colorDuration(duration) } // WriteSummary writes the summary to a writer. func (r *Report) WriteSummary(w io.Writer) error { summary := r.Summarize() // Don't write anything if there are no units if summary.TotalUnits() == 0 { return nil } _, err := fmt.Fprintf(w, "\n") if err != nil { return err } err = summary.Write(w) if err != nil { return err } _, err = fmt.Fprintf(w, "\n") if err != nil { return err } return nil } // Write writes the summary to a writer. func (s *Summary) Write(w io.Writer) error { colorizer := NewColorizer(s.shouldColor) if s.showUnitLevelSummary { return s.writeUnitLevelSummary(w, colorizer) } header := fmt.Sprintf("%s %s %s", colorizer.headingTitleColorizer(runSummaryHeader), colorizer.headingUnitColorizer(fmt.Sprintf("%d units", s.TotalUnits())), s.TotalDurationString(colorizer), ) if err := s.writeSummaryHeader(w, header); err != nil { return err } separatorLine := fmt.Sprintf("%s%s", prefix, strings.Repeat("─", separatorLineLength)) if err := s.writeSummaryHeader(w, separatorLine); err != nil { return err } if s.UnitsSucceeded > 0 { if err := s.writeSummaryEntry( w, colorizer.successColorizer(successLabel), colorizer.successUnitColorizer(strconv.Itoa(s.UnitsSucceeded)), ); err != nil { return err } } if s.UnitsFailed > 0 { if err := s.writeSummaryEntry( w, colorizer.failureColorizer(failureLabel), colorizer.failureUnitColorizer(strconv.Itoa(s.UnitsFailed)), ); err != nil { return err } } if s.EarlyExits > 0 { if err := s.writeSummaryEntry( w, colorizer.exitColorizer(earlyExitLabel), colorizer.exitUnitColorizer(strconv.Itoa(s.EarlyExits)), ); err != nil { return err } } if s.Excluded > 0 { if err := s.writeSummaryEntry( w, colorizer.excludeColorizer(excludeLabel), colorizer.excludeUnitColorizer(strconv.Itoa(s.Excluded)), ); err != nil { return err } } return nil } const ( prefix = " " unitPrefixMultiplier = 2 runSummaryHeader = "❯❯ Run Summary" successLabel = "Succeeded" failureLabel = "Failed" earlyExitLabel = "Early Exits" excludeLabel = "Excluded" separatorLineLength = 28 durationAlignmentOffset = 4 headerUnitCountSpacing = 2 defaultUnitNameLength = 20 headerPaddingAdjustment = 3 separatorPaddingAdjustment = 2 ) func (s *Summary) writeSummaryHeader(w io.Writer, value string) error { _, err := fmt.Fprintf(w, "%s\n", value) if err != nil { return err } return nil } func (s *Summary) writeSummaryEntry(w io.Writer, label string, value string) error { _, err := fmt.Fprintf(w, "%s%s%s%s\n", prefix, label, s.padding(label), value) if err != nil { return err } return nil } // writeUnitLevelSummary writes the summary with unit level summaries grouped by categories func (s *Summary) writeUnitLevelSummary(w io.Writer, colorizer *Colorizer) error { maxUnitNameLength := 0 for _, run := range s.runs { name := run.Path if s.workingDir != "" { name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator)) } if len(name) > maxUnitNameLength { maxUnitNameLength = len(name) } } headerPadding := 0 if maxUnitNameLength > defaultUnitNameLength { headerPadding = maxUnitNameLength - defaultUnitNameLength + headerPaddingAdjustment } header := fmt.Sprintf( "%s %s%s %s", runSummaryHeader, colorizer.headingUnitColorizer(fmt.Sprintf("%d units", s.TotalUnits())), strings.Repeat(" ", headerPadding), s.TotalDurationString(colorizer), ) if err := s.writeSummaryHeader(w, colorizer.headingTitleColorizer(header)); err != nil { return err } separatorAdjustment := 0 if headerPadding > 0 { separatorAdjustment = headerPadding - separatorPaddingAdjustment } separatorLine := fmt.Sprintf("%s%s", prefix, strings.Repeat("─", separatorLineLength+separatorAdjustment)) if err := s.writeSummaryHeader(w, separatorLine); err != nil { return err } resultGroups := map[Result][]*Run{ ResultSucceeded: {}, ResultFailed: {}, ResultEarlyExit: {}, ResultExcluded: {}, } for _, run := range s.runs { resultGroups[run.Result] = append(resultGroups[run.Result], run) } categories := []struct { colorizer func(string) string unitColorizer func(string) string result Result label string count int }{ { colorizer: colorizer.successColorizer, unitColorizer: colorizer.successUnitColorizer, result: ResultSucceeded, label: successLabel, count: s.UnitsSucceeded, }, { colorizer: colorizer.failureColorizer, unitColorizer: colorizer.failureUnitColorizer, result: ResultFailed, label: failureLabel, count: s.UnitsFailed, }, { colorizer: colorizer.exitColorizer, unitColorizer: colorizer.exitUnitColorizer, result: ResultEarlyExit, label: earlyExitLabel, count: s.EarlyExits, }, { colorizer: colorizer.excludeColorizer, unitColorizer: colorizer.excludeUnitColorizer, result: ResultExcluded, label: excludeLabel, count: s.Excluded, }, } for _, category := range categories { if category.count > 0 { categoryHeader := fmt.Sprintf("%s (%d)", category.label, category.count) categoryHeaderColored := category.colorizer(categoryHeader) if _, err := fmt.Fprintf(w, "%s%s\n", prefix, categoryHeaderColored); err != nil { return err } runs := resultGroups[category.result] slices.SortFunc(runs, func(a, b *Run) int { aDuration := a.Ended.Sub(a.Started) bDuration := b.Ended.Sub(b.Started) return int(bDuration - aDuration) }) for _, run := range runs { if err := s.writeUnitDuration(w, run, colorizer, category.unitColorizer); err != nil { return err } } } } return nil } // writeUnitDuration writes unit duration with cleaner formatting func (s *Summary) writeUnitDuration(w io.Writer, run *Run, colorizer *Colorizer, unitColorizer func(string) string) error { duration := run.Ended.Sub(run.Started) name := run.Path if s.workingDir != "" { name = strings.TrimPrefix(name, s.workingDir+string(os.PathSeparator)) } padding := s.unitDurationPadding(name, colorizer) _, err := fmt.Fprintf( w, "%s%s%s%s\n", strings.Repeat(prefix, unitPrefixMultiplier), unitColorizer(name), padding, colorizer.colorDuration(duration), ) if err != nil { return err } return nil } func (s *Summary) padding(label string) string { headerUnitCountVisualPosition := s.visualLength(runSummaryHeader) + headerUnitCountSpacing currentLabelLength := s.visualLength(label) currentPosition := len(prefix) + currentLabelLength paddingNeeded := headerUnitCountVisualPosition - currentPosition paddingNeeded -= 4 if paddingNeeded < 0 { paddingNeeded = 0 } padding := strings.Repeat(s.padder, paddingNeeded) whitespaceLen := 2 if len(padding) < whitespaceLen { return " " } padding = " " + padding[1:len(padding)-1] + " " return strings.ReplaceAll(padding, s.padder, " ") } // ansiRegex is used to remove ANSI escape codes from strings. // We compile it here to avoid re-compiling it on every call to visualLength. var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) // visualLength calculates the visual length of a string by removing ANSI escape codes func (s *Summary) visualLength(text string) int { cleanText := ansiRegex.ReplaceAllString(text, "") return len(cleanText) } // unitDurationPadding calculates padding for unit names to align durations with header func (s *Summary) unitDurationPadding(name string, colorizer *Colorizer) string { maxUnitNameLength := 0 for _, run := range s.runs { runName := run.Path if s.workingDir != "" { runName = strings.TrimPrefix(runName, s.workingDir+string(os.PathSeparator)) } if len(runName) > maxUnitNameLength { maxUnitNameLength = len(runName) } } headerPadding := 0 if maxUnitNameLength > defaultUnitNameLength { headerPadding = maxUnitNameLength - defaultUnitNameLength + headerPaddingAdjustment } headerPrefix := fmt.Sprintf("%s %d units ", runSummaryHeader, s.TotalUnits()) headerDurationColumn := len(headerPrefix) + headerPadding unitPrefix := strings.Repeat(prefix, unitPrefixMultiplier) currentPosition := len(unitPrefix) + len(name) paddingNeeded := max(1, headerDurationColumn-currentPosition-durationAlignmentOffset) padding := strings.Repeat(s.padder, paddingNeeded) whitespaceLen := 2 if len(padding) < whitespaceLen { return " " } padding = " " + padding[1:len(padding)-1] + " " return colorizer.paddingColorizer(padding) } ================================================ FILE: internal/report/writer.go ================================================ package report import ( "bytes" "encoding/csv" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/invopop/jsonschema" "github.com/xeipuuv/gojsonschema" ) const ( // csvFieldCount is the expected number of fields in a CSV report row. csvFieldCount = 9 // csvRowOffset accounts for: 0-indexed loop (i starts at 0) + skipped header row. csvRowOffset = 2 ) // JSONRun represents a run in JSON format. type JSONRun struct { // Started is the time when the run started. Started time.Time `json:"Started" jsonschema:"required"` // Ended is the time when the run ended. Ended time.Time `json:"Ended" jsonschema:"required"` // Reason is the reason for the run result, if any. Reason *string `json:"Reason,omitempty" jsonschema:"enum=retry succeeded,enum=error ignored,enum=run error,enum=exclude block,enum=ancestor error"` // Cause is the cause of the run result, if any. Cause *string `json:"Cause,omitempty"` // Name is the name of the run. Name string `json:"Name" jsonschema:"required"` // Result is the result of the run. Result string `json:"Result" jsonschema:"required,enum=succeeded,enum=failed,enum=early exit,enum=excluded"` // Ref is the worktree reference (e.g., git commit, branch). Ref string `json:"Ref,omitempty"` // Cmd is the terraform command (plan, apply, etc.). Cmd string `json:"Cmd,omitempty"` // Args are the terraform CLI arguments. Args []string `json:"Args,omitempty"` } // JSONRuns is a slice of JSONRun entries with helper methods. type JSONRuns []JSONRun // ParseJSONRuns parses a JSON report from a byte slice. // Returns a slice of JSONRun entries or an error if parsing fails. func ParseJSONRuns(data []byte) (JSONRuns, error) { var runs JSONRuns if err := json.Unmarshal(data, &runs); err != nil { return nil, fmt.Errorf("failed to parse JSON report: %w", err) } return runs, nil } // ParseJSONRunsFromFile reads and parses a JSON report from a file. // Returns a slice of JSONRun entries or an error if reading, validation, or parsing fails. // The report is validated against the JSON schema before parsing. func ParseJSONRunsFromFile(path string) (JSONRuns, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read report file %s: %w", path, err) } if err := validateJSONReport(data); err != nil { return nil, err } return ParseJSONRuns(data) } // FindByName searches for a run by name. // Returns the run if found, or nil if not found. func (runs JSONRuns) FindByName(name string) *JSONRun { for i := range runs { if runs[i].Name == name { return &runs[i] } } return nil } // Names returns a slice of all run names. // Useful for debugging and assertions in tests. func (runs JSONRuns) Names() []string { names := make([]string, len(runs)) for i := range runs { names[i] = runs[i].Name } return names } // CSVRun represents a run parsed from CSV format. type CSVRun struct { Name string Started string Ended string Result string Reason string Cause string Ref string Cmd string Args string } // CSVRuns is a slice of CSVRun entries with helper methods. type CSVRuns []CSVRun // ParseCSVRuns parses a CSV report from a byte slice. // Returns a slice of CSVRun entries or an error if parsing fails. // The first row is expected to be a header row and is skipped. func ParseCSVRuns(data []byte) (CSVRuns, error) { reader := csv.NewReader(bytes.NewReader(data)) records, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("failed to parse CSV report: %w", err) } // Skip header row if len(records) < 1 { return CSVRuns{}, nil } runs := make(CSVRuns, 0, len(records)-1) for i, record := range records[1:] { if len(record) < csvFieldCount { return nil, fmt.Errorf("invalid CSV record at row %d: expected %d fields, got %d", i+csvRowOffset, csvFieldCount, len(record)) } runs = append(runs, CSVRun{ Name: record[0], Started: record[1], Ended: record[2], Result: record[3], Reason: record[4], Cause: record[5], Ref: record[6], Cmd: record[7], Args: record[8], }) } return runs, nil } // ParseCSVRunsFromFile reads and parses a CSV report from a file. // Returns a slice of CSVRun entries or an error if reading or parsing fails. func ParseCSVRunsFromFile(path string) (CSVRuns, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read report file %s: %w", path, err) } return ParseCSVRuns(data) } // FindByName searches for a run by name. // Returns the run if found, or nil if not found. func (runs CSVRuns) FindByName(name string) *CSVRun { for i := range runs { if runs[i].Name == name { return &runs[i] } } return nil } // Names returns a slice of all run names. // Useful for debugging and assertions in tests. func (runs CSVRuns) Names() []string { names := make([]string, len(runs)) for i := range runs { names[i] = runs[i].Name } return names } // SchemaValidationError represents a schema validation error with details. type SchemaValidationError struct { Errors []string } func (e *SchemaValidationError) Error() string { return fmt.Sprintf("schema validation failed with %d error(s): %v", len(e.Errors), e.Errors) } // validateJSONReport validates a JSON report against the schema. // Returns nil if valid, or a SchemaValidationError with details if invalid. func validateJSONReport(data []byte) error { schemaBytes, err := json.Marshal(generateReportSchema()) if err != nil { return fmt.Errorf("failed to generate schema: %w", err) } schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) documentLoader := gojsonschema.NewBytesLoader(data) result, err := gojsonschema.Validate(schemaLoader, documentLoader) if err != nil { return fmt.Errorf("failed to validate report: %w", err) } if !result.Valid() { errors := make([]string, len(result.Errors())) for i, validationErr := range result.Errors() { errors[i] = validationErr.String() } return &SchemaValidationError{Errors: errors} } return nil } // WriteToFile writes the report to a file. func (r *Report) WriteToFile(path string) error { tmpFile, err := os.CreateTemp("", "terragrunt-report-*") if err != nil { return err } r.mu.Lock() r.SortRuns() r.mu.Unlock() switch r.format { case FormatCSV: err = r.WriteCSV(tmpFile) case FormatJSON: err = r.WriteJSON(tmpFile) default: return fmt.Errorf("unsupported format: %s", r.format) } if err != nil { return fmt.Errorf("failed to write report: %w", err) } if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close report file: %w", err) } if r.workingDir != "" && !filepath.IsAbs(path) { path = filepath.Join(r.workingDir, path) } return util.MoveFile(tmpFile.Name(), path) } // WriteCSV writes the report to a writer in CSV format. func (r *Report) WriteCSV(w io.Writer) error { r.mu.RLock() defer r.mu.RUnlock() csvWriter := csv.NewWriter(w) defer csvWriter.Flush() err := csvWriter.Write([]string{ "Name", "Started", "Ended", "Result", "Reason", "Cause", "Ref", "Cmd", "Args", }) if err != nil { return err } for _, run := range r.Runs { run.mu.RLock() defer run.mu.RUnlock() workingDir := effectiveWorkingDir(run, r.workingDir) name := nameOfPath(run.Path, workingDir) started := run.Started.Format(time.RFC3339) ended := run.Ended.Format(time.RFC3339) result := string(run.Result) reason := "" if run.Reason != nil { reason = string(*run.Reason) } cause := "" if run.Cause != nil { cause = string(*run.Cause) if reason == string(ReasonAncestorError) && workingDir != "" { cause = strings.TrimPrefix(cause, workingDir+string(os.PathSeparator)) } } // Format Args as pipe-separated string for CSV to avoid conflicts with CSV column separator args := strings.Join(run.Args, "|") err := csvWriter.Write([]string{ name, started, ended, result, reason, cause, run.Ref, run.Cmd, args, }) if err != nil { return err } } return nil } // WriteJSON writes the report to a writer in JSON format. func (r *Report) WriteJSON(w io.Writer) error { r.mu.RLock() defer r.mu.RUnlock() runs := make([]JSONRun, 0, len(r.Runs)) for _, run := range r.Runs { run.mu.RLock() defer run.mu.RUnlock() workingDir := effectiveWorkingDir(run, r.workingDir) name := nameOfPath(run.Path, workingDir) jsonRun := JSONRun{ Name: name, Started: run.Started, Ended: run.Ended, Ref: run.Ref, Cmd: run.Cmd, Args: run.Args, Result: string(run.Result), } if run.Reason != nil { reason := string(*run.Reason) jsonRun.Reason = &reason } if run.Cause != nil { cause := string(*run.Cause) if run.Reason != nil && *run.Reason == ReasonAncestorError && workingDir != "" { cause = strings.TrimPrefix(cause, workingDir+string(os.PathSeparator)) } jsonRun.Cause = &cause } runs = append(runs, jsonRun) } jsonBytes, err := json.MarshalIndent(runs, "", " ") if err != nil { return err } jsonBytes = append(jsonBytes, '\n') _, err = w.Write(jsonBytes) return err } // WriteSchemaToFile writes a JSON schema for the report to a file. func (r *Report) WriteSchemaToFile(path string) error { tmpFile, err := os.CreateTemp("", "terragrunt-schema-*") if err != nil { return err } if err := WriteSchema(tmpFile); err != nil { return fmt.Errorf("failed to write schema: %w", err) } if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close schema file: %w", err) } if r.workingDir != "" && !filepath.IsAbs(path) { path = filepath.Join(r.workingDir, path) } return util.MoveFile(tmpFile.Name(), path) } // WriteSchema writes a JSON schema for the report to a writer. func WriteSchema(w io.Writer) error { arraySchema := generateReportSchema() jsonBytes, err := json.MarshalIndent(arraySchema, "", " ") if err != nil { return err } jsonBytes = append(jsonBytes, '\n') _, err = w.Write(jsonBytes) return err } // nameOfPath returns a name for a path given a working directory. // // The logic for determining the name of a given path is: // // - If the path is the same as the working directory, return the base name of the path. // This is usually only relevant when performing a `run --all` in a unit directory. // // - If the path is not a subdirectory of the working directory, return the path as is. // // - Otherwise, return the path relative to the working directory, with any leading slashes removed. func nameOfPath(path string, workingDir string) string { // If the path is the same as the working directory, // return the base name of the path. if path == workingDir { return filepath.Base(path) } // If the path is not a subdirectory of the working directory, // return the path as is. if !strings.HasPrefix(path, workingDir) { return path } path = strings.TrimPrefix(path, workingDir) path = strings.TrimPrefix(path, string(os.PathSeparator)) return path } // effectiveWorkingDir returns the working directory to use for path computation. // If the run has a DiscoveryWorkingDir set (for worktree scenarios), use that. // Otherwise, fall back to the report's workingDir. func effectiveWorkingDir(run *Run, reportWorkingDir string) string { if run.DiscoveryWorkingDir != "" { return run.DiscoveryWorkingDir } return reportWorkingDir } // generateReportSchema generates the JSON schema for report validation. func generateReportSchema() *jsonschema.Schema { reflector := jsonschema.Reflector{ DoNotReference: true, } schema := reflector.Reflect(&JSONRun{}) schema.Description = "Schema for Terragrunt run report" schema.Title = "Terragrunt Run Report Schema" schema.Version = "" schema.ID = "" return &jsonschema.Schema{ Version: "https://json-schema.org/draft/2020-12/schema", ID: "https://docs.terragrunt.com/schemas/run/report/v4/schema.json", Type: "array", Title: "Terragrunt Run Report Schema", Description: "Array of Terragrunt runs", Items: schema, } } ================================================ FILE: internal/retry/defaults.go ================================================ // Package retry provides default retry configuration for Terragrunt. package retry import "time" // DefaultMaxAttempts is the default number of retry attempts. const DefaultMaxAttempts = 3 // DefaultSleepInterval is the default sleep interval between retries. const DefaultSleepInterval = 5 * time.Second // DefaultRetryableErrors is a list of errors that are considered transient and // should be retried. // // It's a list of recurring transient errors encountered when calling terraform. // If any of these match, we'll retry the command. var DefaultRetryableErrors = []string{ "(?s).*Failed to load state.*tcp.*timeout.*", "(?s).*Failed to load backend.*TLS handshake timeout.*", "(?s).*Creating metric alarm failed.*request to update this alarm is in progress.*", "(?s).*Error installing provider.*TLS handshake timeout.*", "(?s).*Error configuring the backend.*TLS handshake timeout.*", "(?s).*Error installing provider.*tcp.*timeout.*", "(?s).*Error installing provider.*tcp.*connection reset by peer.*", "NoSuchBucket: The specified bucket does not exist", "(?s).*Error creating SSM parameter: TooManyUpdates:.*", "(?s).*app.terraform.io.*: 429 Too Many Requests.*", "(?s).*ssh_exchange_identification.*Connection closed by remote host.*", "(?s).*Client\\.Timeout exceeded while awaiting headers.*", "(?s).*Could not download module.*The requested URL returned error: 429.*", "(?s).*net/http: TLS.*handshake timeout.*", "(?s).*could not query provider registry.*context deadline exceeded.*", } ================================================ FILE: internal/runner/common/options.go ================================================ package common import ( "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) // Option applies configuration to a StackRunner. type Option interface { Apply(stack StackRunner) } // optionImpl is a lightweight Option implementation that wraps an apply function // and optionally carries HCL parser options. type optionImpl struct { apply func(StackRunner) parserOptions []hclparse.Option } func (o optionImpl) Apply(stack StackRunner) { if o.apply != nil { o.apply(stack) } } // ParseOptionsProvider exposes HCL parser options carried by an Option. type ParseOptionsProvider interface { GetParseOptions() []hclparse.Option } // GetParseOptions returns the HCL parser options attached to the option, if any. func (o optionImpl) GetParseOptions() []hclparse.Option { if len(o.parserOptions) > 0 { return o.parserOptions } return nil } // WithParseOptions provides custom HCL parser options to both discovery and stack execution. func WithParseOptions(parserOptions []hclparse.Option) Option { return optionImpl{ // No-op apply for runner; discovery picks up parser options via GetParseOptions apply: func(StackRunner) {}, parserOptions: parserOptions, } } // WorktreeOption carries worktrees through the runner pipeline for git filter expressions. type WorktreeOption struct { Worktrees *worktrees.Worktrees } // Apply is a no-op for runner (worktrees are used in discovery, not runner execution). func (o WorktreeOption) Apply(stack StackRunner) {} // WithWorktrees provides git worktrees to discovery for git filter expressions. func WithWorktrees(w *worktrees.Worktrees) Option { return WorktreeOption{Worktrees: w} } ================================================ FILE: internal/runner/common/runner.go ================================================ // Package common defines minimal runner interfaces to allow multiple implementations. package common import ( "context" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // StackRunner is the abstraction for running a stack of units. // Implemented by runnerpool.Runner and any alternate runner implementations. type StackRunner interface { // Run executes all units in the stack according to the specified Terraform command and options. Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, r *report.Report) error // LogUnitDeployOrder logs the order in which units will be deployed for the given Terraform command. LogUnitDeployOrder(l log.Logger, terraformCmd string, isDestroy bool, showAbsPaths bool) error // JSONUnitDeployOrder returns the deployment order of units as a JSON string. JSONUnitDeployOrder(isDestroy bool, showAbsPaths bool) (string, error) // ListStackDependentUnits returns a map of each unit to the list of units that depend on it. ListStackDependentUnits() map[string][]string // GetStack retrieves the underlying Stack object managed by this runner. GetStack() *component.Stack } ================================================ FILE: internal/runner/common/unit_runner.go ================================================ package common import ( "bytes" "context" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // UnitStatus represents the status of a unit during execution. type UnitStatus int const ( Waiting UnitStatus = iota Running Finished ) // UnitRunner handles the logic for running a single component.Unit. type UnitRunner struct { Err error Unit *component.Unit Status UnitStatus } // NewUnitRunner creates a UnitRunner from a component.Unit. func NewUnitRunner(unit *component.Unit) *UnitRunner { return &UnitRunner{ Unit: unit, Status: Waiting, } } func (runner *UnitRunner) runTerragrunt( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, r *report.Report, cfg *runcfg.RunConfig, credsGetter *creds.Getter, ) error { l.Debugf("Running %s", util.RelPathForLog(opts.RootWorkingDir, runner.Unit.Path(), opts.Writers.LogShowAbsPaths)) defer func() { // Flush buffered output for this unit, if the writer supports it. if err := component.FlushOutput(runner.Unit, opts.Writers.Writer); err != nil { l.Errorf("Error flushing output for unit %s: %v", runner.Unit.Path(), err) } }() // Only create report entries if report is not nil if r != nil { unitPath := runner.Unit.Path() unitPath = filepath.Clean(unitPath) // Pass the discovery context fields for worktree scenarios var ensureOpts []report.EndOption if discoveryCtx := runner.Unit.DiscoveryContext(); discoveryCtx != nil { ensureOpts = append( ensureOpts, report.WithDiscoveryWorkingDir(discoveryCtx.WorkingDir), report.WithRef(discoveryCtx.Ref), report.WithCmd(discoveryCtx.Cmd), report.WithArgs(discoveryCtx.Args), ) } if _, err := r.EnsureRun(l, unitPath, ensureOpts...); err != nil { return err } } // Use a unit-scoped detailed exit code so retries in this unit don't clobber global state globalExitCode := tf.DetailedExitCodeFromContext(ctx) unitExitCode := tf.NewDetailedExitCodeMap() ctx = tf.ContextWithDetailedExitCode(ctx, unitExitCode) runErr := run.Run(ctx, l, configbridge.NewRunOptions(opts), r, cfg, credsGetter) // Store the unit exit code in the global map using the unit path as key. if globalExitCode != nil { unitPath := runner.Unit.Path() code := unitExitCode.Get(unitPath) globalExitCode.Set(unitPath, code) } // End the run with appropriate result (only if report is not nil) if r != nil { unitPath := runner.Unit.Path() unitPath = filepath.Clean(unitPath) if runErr != nil { if endErr := r.EndRun( l, unitPath, report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError), report.WithCauseRunError(runErr.Error()), ); endErr != nil { l.Errorf("Error ending run for unit %s: %v", unitPath, endErr) } } else { if endErr := r.EndRun( l, unitPath, report.WithResult(report.ResultSucceeded), ); endErr != nil { l.Errorf("Error ending run for unit %s: %v", unitPath, endErr) } } } return runErr } // Run executes a component.Unit right now. func (runner *UnitRunner) Run( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, r *report.Report, cfg *runcfg.RunConfig, credsGetter *creds.Getter, ) error { runner.Status = Running if opts == nil { return nil } if err := runner.runTerragrunt(ctx, l, opts, r, cfg, credsGetter); err != nil { return err } // convert terragrunt output to json if runner.Unit.OutputJSONFile(opts.RootWorkingDir, opts.JSONOutputFolder) != "" { jsonLogger, jsonOptions, err := opts.CloneWithConfigPath( l, opts.TerragruntConfigPath, ) if err != nil { return err } stdout := bytes.Buffer{} jsonOptions.ForwardTFStdout = true jsonOptions.JSONLogFormat = false jsonOptions.Writers.Writer = &stdout jsonOptions.TerraformCommand = tf.CommandNameShow jsonOptions.TerraformCliArgs = iacargs.New(tf.CommandNameShow, "-json", runner.Unit.PlanFile(opts.RootWorkingDir, opts.OutputFolder, opts.JSONOutputFolder, opts.TerraformCommand)) // Use an ad-hoc report to avoid polluting the main report adhocReport := report.NewReport() if err := run.Run(ctx, jsonLogger, configbridge.NewRunOptions(jsonOptions), adhocReport, cfg, credsGetter); err != nil { return err } // save the json output to the file plan file outputFile := runner.Unit.OutputJSONFile(opts.RootWorkingDir, opts.JSONOutputFolder) jsonDir := filepath.Dir(outputFile) if err := os.MkdirAll(jsonDir, os.ModePerm); err != nil { return err } if err := os.WriteFile(outputFile, stdout.Bytes(), os.ModePerm); err != nil { return err } } return nil } ================================================ FILE: internal/runner/graph/graph.go ================================================ // Package graph implements the logic for running commands against the // graph of dependencies for the unit in the current working directory. package graph import ( "context" "errors" "fmt" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/internal/os/stdout" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/options" ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { // Get credentials BEFORE config parsing — sops_decrypt_file() and // get_aws_account_id() in locals need auth-provider credentials // available in opts.Env during HCL evaluation. // *Getter discarded: graph.Run only needs creds in opts.Env for initial config parse. // Per-unit creds are re-fetched in runnerpool task (intentional: each unit may have // different opts after clone). if _, err := creds.ObtainCredsForParsing(ctx, l, opts.AuthProviderCmd, opts.Env, configbridge.ShellRunOptsFromOpts(opts)); err != nil { return err } ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) cfg, err := config.ReadTerragruntConfig(ctx, l, pctx, pctx.ParserOptions) if err != nil { return err } if cfg == nil { return errors.New("terragrunt was not able to render the config as json because it received no config. This is almost certainly a bug in Terragrunt. Please open an issue on github.com/gruntwork-io/terragrunt with this message and the contents of your terragrunt.hcl") } // consider root for graph identification passed destroy-graph-root argument rootDir := opts.GraphRoot // if destroy-graph-root is empty, use git to find top level dir. // may cause issues if in the same repo exist unrelated modules which will generate errors when scanning. if rootDir == "" { gitRoot, gitRootErr := shell.GitTopLevelDir(ctx, l, opts.Env, opts.WorkingDir) if gitRootErr != nil { return gitRootErr } rootDir = gitRoot } // Clone options and set RootWorkingDir to rootDir so discovery starts from the graph root // This allows discovering all modules including dependents (modules that depend on the working dir) graphOpts := opts.Clone() graphOpts.RootWorkingDir = rootDir runnerOpts := make([]common.Option, 0, 1) r := report.NewReport().WithWorkingDir(opts.WorkingDir) if l.Formatter().DisabledColors() || stdout.IsRedirected() { r.WithDisableColor() } if opts.ReportFormat != "" { r.WithFormat(opts.ReportFormat) } if opts.SummaryPerUnit { r.WithShowUnitLevelSummary() } // Limit graph to the working directory and its dependents. // The prefix ellipsis means "include dependents"; target is included by default. pathExpr, err := filter.NewPathFilter(opts.WorkingDir) if err != nil { return fmt.Errorf("failed to create path filter for %s: %w", opts.WorkingDir, err) } graphExpr := filter.NewGraphExpression(pathExpr).WithDependents() graphOpts.Filters = filter.Filters{filter.NewFilter(graphExpr, graphExpr.String())} if opts.ReportSchemaFile != "" { defer r.WriteSchemaToFile(opts.ReportSchemaFile) //nolint:errcheck } if opts.ReportFile != "" { defer r.WriteToFile(opts.ReportFile) //nolint:errcheck } if !opts.SummaryDisable { defer func() { if err := r.WriteSummary(opts.Writers.Writer); err != nil { l.Warnf("Failed to write summary: %v", err) } }() } rnr, err := runner.NewStackRunner(ctx, l, graphOpts, runnerOpts...) if err != nil { return err } return runall.RunAllOnStack(ctx, l, graphOpts, rnr, r) } ================================================ FILE: internal/runner/run/context.go ================================================ package run import ( "context" "github.com/gruntwork-io/terragrunt/internal/cache" ) type configKey byte const ( versionCacheContextKey configKey = iota versionCacheName = "versionCache" ) // WithRunVersionCache initializes the version cache in the context for the run package. func WithRunVersionCache(ctx context.Context) context.Context { ctx = context.WithValue(ctx, versionCacheContextKey, cache.NewCache[string](versionCacheName)) return ctx } // GetRunVersionCache retrieves the version cache from the context for the run package. func GetRunVersionCache(ctx context.Context) *cache.Cache[string] { return cache.ContextCache[string](ctx, versionCacheContextKey) } ================================================ FILE: internal/runner/run/creds/getter.go ================================================ // Package creds provides a way to obtain credentials through different providers and set them to `opts.Env`. package creds import ( "context" "maps" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/log" ) type Getter struct { obtainedCreds map[string]*providers.Credentials } func NewGetter() *Getter { return &Getter{ obtainedCreds: make(map[string]*providers.Credentials), } } // ObtainAndUpdateEnvIfNecessary obtains credentials through different providers and sets them to the provided env map. func (getter *Getter) ObtainAndUpdateEnvIfNecessary(ctx context.Context, l log.Logger, env map[string]string, authProviders ...providers.Provider) error { for _, provider := range authProviders { creds, err := provider.GetCredentials(ctx, l) if err != nil { return err } if creds == nil { continue } for providerName, prevCreds := range getter.obtainedCreds { if prevCreds.Name == creds.Name { l.Warnf("%s credentials obtained using %s are overwritten by credentials obtained using %s.", creds.Name, providerName, provider.Name()) } } getter.obtainedCreds[provider.Name()] = creds maps.Copy(env, creds.Envs) } return nil } // ObtainCredsForParsing creates a new Getter, obtains external-command // credentials, and populates env before HCL parsing. // Use when sops_decrypt_file() or get_aws_account_id() may appear in locals. // See https://github.com/gruntwork-io/terragrunt/issues/5515 func ObtainCredsForParsing(ctx context.Context, l log.Logger, authProviderCmd string, env map[string]string, shellOpts *shell.ShellOptions) (*Getter, error) { g := NewGetter() if err := g.ObtainAndUpdateEnvIfNecessary(ctx, l, env, externalcmd.NewProvider(l, authProviderCmd, shellOpts)); err != nil { return nil, err } return g, nil } ================================================ FILE: internal/runner/run/creds/providers/amazonsts/provider.go ================================================ // Package amazonsts provides a credentials provider that obtains credentials by making API requests to Amazon STS. package amazonsts import ( "context" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Provider obtains credentials by making API requests to Amazon STS. type Provider struct { env map[string]string iamRoleOpts iam.RoleOptions } // NewProvider returns a new Provider instance. func NewProvider(l log.Logger, iamRoleOpts iam.RoleOptions, env map[string]string) providers.Provider { return &Provider{ iamRoleOpts: iamRoleOpts, env: env, } } // Name implements providers.Name func (provider *Provider) Name() string { return "API calls to Amazon STS" } // GetCredentials implements providers.GetCredentials func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*providers.Credentials, error) { iamRoleOpts := provider.iamRoleOpts if iamRoleOpts.RoleARN == "" { return nil, nil } if cached, hit := credentialsCache.Get(ctx, iamRoleOpts.RoleARN); hit { l.Debugf("Using cached credentials for IAM role %s.", iamRoleOpts.RoleARN) return cached, nil } l.Debugf("Assuming IAM role %s with a session duration of %d seconds.", iamRoleOpts.RoleARN, iamRoleOpts.AssumeRoleDuration) resp, err := awshelper.AssumeIamRole(ctx, iamRoleOpts, "", provider.env) if err != nil { return nil, err } creds := &providers.Credentials{ Name: providers.AWSCredentials, Envs: map[string]string{ "AWS_ACCESS_KEY_ID": aws.ToString(resp.AccessKeyId), "AWS_SECRET_ACCESS_KEY": aws.ToString(resp.SecretAccessKey), "AWS_SESSION_TOKEN": aws.ToString(resp.SessionToken), "AWS_SECURITY_TOKEN": aws.ToString(resp.SessionToken), }, } credentialsCache.Put(ctx, iamRoleOpts.RoleARN, creds, time.Now().Add(time.Duration(iamRoleOpts.AssumeRoleDuration)*time.Second)) return creds, nil } // credentialsCache is a cache of credentials. var credentialsCache = cache.NewExpiringCache[*providers.Credentials]("credentialsCache") ================================================ FILE: internal/runner/run/creds/providers/externalcmd/provider.go ================================================ // Package externalcmd provides a provider that runs an external command that returns a json string with credentials. package externalcmd import ( "context" "encoding/json" "fmt" "maps" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/amazonsts" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mattn/go-shellwords" ) // Provider runs external command that returns a json string with credentials. type Provider struct { runOpts *shell.ShellOptions authProviderCmd string } // NewProvider returns a new Provider instance. func NewProvider(l log.Logger, authProviderCmd string, runOpts *shell.ShellOptions) providers.Provider { return &Provider{ authProviderCmd: authProviderCmd, runOpts: runOpts, } } // Name implements providers.Name func (provider *Provider) Name() string { return fmt.Sprintf("external %s command", provider.authProviderCmd) } // GetCredentials implements providers.GetCredentials func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*providers.Credentials, error) { if provider.authProviderCmd == "" { return nil, nil } parser := shellwords.NewParser() // Normalize Windows paths before parsing - shellwords treats backslashes as escape characters parts, err := parser.Parse(filepath.ToSlash(provider.authProviderCmd)) if err != nil { return nil, errors.Errorf("failed to parse auth provider command: %w", err) } command := parts[0] args := []string{} if len(parts) > 1 { args = parts[1:] } output, err := shell.RunCommandWithOutput(ctx, l, provider.runOpts, "", true, false, command, args...) if err != nil { return nil, err } if output.Stdout.String() == "" { return nil, errors.Errorf( "command %s completed successfully, but the response does not contain JSON string", provider.authProviderCmd, ) } resp := &Response{Envs: make(map[string]string)} if err := json.Unmarshal(output.Stdout.Bytes(), &resp); err != nil { return nil, errors.Errorf("command %s returned a response with invalid JSON format", command) } creds := &providers.Credentials{ Name: providers.AWSCredentials, Envs: resp.Envs, } if resp.AWSCredentials != nil { if envs := resp.AWSCredentials.Envs(ctx, l, provider.authProviderCmd); envs != nil { l.Debugf("Obtaining AWS credentials from the %s.", provider.Name()) maps.Copy(creds.Envs, envs) } return creds, nil } if resp.AWSRole != nil { if envs := resp.AWSRole.Envs(ctx, l, provider.authProviderCmd); envs != nil { l.Debugf("Assuming AWS role %s using the %s.", resp.AWSRole.RoleARN, provider.Name()) maps.Copy(creds.Envs, envs) } return creds, nil } return creds, nil } // Response is the JSON response expected from an auth provider command. type Response struct { // AWSCredentials contains AWS credentials to set as environment variables. AWSCredentials *AWSCredentials `json:"awsCredentials,omitempty"` // AWSRole contains AWS role information for role assumption. AWSRole *AWSRole `json:"awsRole,omitempty"` // Envs contains additional environment variables to set. Envs map[string]string `json:"envs,omitempty"` } // AWSCredentials is the JSON schema for direct AWS credentials. type AWSCredentials struct { // AccessKeyID is the AWS access key ID. AccessKeyID string `json:"ACCESS_KEY_ID" jsonschema:"required"` // SecretAccessKey is the AWS secret access key. SecretAccessKey string `json:"SECRET_ACCESS_KEY" jsonschema:"required"` // SessionToken is the AWS session token (optional). SessionToken string `json:"SESSION_TOKEN,omitempty"` } // AWSRole is the JSON schema for AWS role assumption. type AWSRole struct { // RoleARN is the ARN of the IAM role to assume. RoleARN string `json:"roleARN" jsonschema:"required"` // RoleSessionName is the session name for the assumed role. RoleSessionName string `json:"roleSessionName,omitempty"` // WebIdentityToken is the web identity token for OIDC-based role assumption. WebIdentityToken string `json:"webIdentityToken,omitempty"` // Duration is the duration in seconds for the assumed role session. Duration int64 `json:"duration,omitempty" jsonschema:"minimum=0"` } func (role *AWSRole) Envs(ctx context.Context, l log.Logger, authProviderCmd string) map[string]string { if role.RoleARN == "" { l.Warnf("The command %s completed successfully, but AWS role assumption contains empty required value: roleARN, nothing is being done.", authProviderCmd) return nil } sessionName := role.RoleSessionName if sessionName == "" { sessionName = iam.GetDefaultAssumeRoleSessionName() } duration := role.Duration if duration == 0 { duration = iam.DefaultAssumeRoleDuration } iamRoleOpts := iam.RoleOptions{ RoleARN: role.RoleARN, AssumeRoleDuration: duration, AssumeRoleSessionName: sessionName, } if role.WebIdentityToken != "" { iamRoleOpts.WebIdentityToken = role.WebIdentityToken } provider := amazonsts.NewProvider(l, iamRoleOpts, nil) creds, err := provider.GetCredentials(ctx, l) if err != nil { l.Warnf("Failed to assume role %s: %v", role.RoleARN, err) return nil } if creds == nil { l.Warnf("The command %s completed successfully, but failed to assume role %s, nothing is being done.", authProviderCmd, role.RoleARN) return nil } envs := map[string]string{ "AWS_ACCESS_KEY_ID": creds.Envs["AWS_ACCESS_KEY_ID"], "AWS_SECRET_ACCESS_KEY": creds.Envs["AWS_SECRET_ACCESS_KEY"], "AWS_SESSION_TOKEN": creds.Envs["AWS_SESSION_TOKEN"], "AWS_SECURITY_TOKEN": creds.Envs["AWS_SESSION_TOKEN"], } return envs } func (creds *AWSCredentials) Envs(_ context.Context, l log.Logger, authProviderCmd string) map[string]string { var emptyFields []string if creds.AccessKeyID == "" { emptyFields = append(emptyFields, "ACCESS_KEY_ID") } if creds.SecretAccessKey == "" { emptyFields = append(emptyFields, "SECRET_ACCESS_KEY") } if len(emptyFields) > 0 { l.Warnf("The command %s completed successfully, but AWS credentials contains empty required values: %s, nothing is being done.", authProviderCmd, strings.Join(emptyFields, ", ")) return nil } envs := map[string]string{ "AWS_ACCESS_KEY_ID": creds.AccessKeyID, "AWS_SECRET_ACCESS_KEY": creds.SecretAccessKey, "AWS_SESSION_TOKEN": creds.SessionToken, "AWS_SECURITY_TOKEN": creds.SessionToken, } return envs } ================================================ FILE: internal/runner/run/creds/providers/externalcmd/schema.go ================================================ package externalcmd import ( "encoding/json" "fmt" "github.com/invopop/jsonschema" "github.com/xeipuuv/gojsonschema" ) // SchemaValidationError represents a schema validation error with details. type SchemaValidationError struct { Errors []string } func (e *SchemaValidationError) Error() string { return fmt.Sprintf( "Auth provider command response schema validation failed with %d error(s): %v", len(e.Errors), e.Errors, ) } // ValidateResponse validates a JSON response against the auth provider command schema. // Returns nil if valid, or a SchemaValidationError with details if invalid. func ValidateResponse(data []byte) error { schemaBytes, err := json.Marshal(generateResponseSchema()) if err != nil { return fmt.Errorf("failed to generate schema: %w", err) } schemaLoader := gojsonschema.NewBytesLoader(schemaBytes) documentLoader := gojsonschema.NewBytesLoader(data) result, err := gojsonschema.Validate(schemaLoader, documentLoader) if err != nil { return fmt.Errorf("failed to validate response: %w", err) } if !result.Valid() { errors := make([]string, len(result.Errors())) for i, validationErr := range result.Errors() { errors[i] = validationErr.String() } return &SchemaValidationError{Errors: errors} } return nil } // generateResponseSchema generates the JSON schema for auth provider command response validation. func generateResponseSchema() *jsonschema.Schema { reflector := jsonschema.Reflector{ DoNotReference: true, } schema := reflector.Reflect(&Response{}) schema.Description = "Schema for the JSON response expected from an auth provider command" schema.Title = "Terragrunt Auth Provider Command Response Schema" schema.ID = "https://docs.terragrunt.com/schemas/auth-provider-cmd/v2/schema.json" return schema } ================================================ FILE: internal/runner/run/creds/providers/externalcmd/schema_test.go ================================================ package externalcmd_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestValidateResponse(t *testing.T) { t.Parallel() tests := []struct { name string input string expectError bool errorCount int }{ { name: "empty object is valid", input: `{}`, expectError: false, }, { name: "valid awsCredentials", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-access-key" } }`, expectError: false, }, { name: "valid awsCredentials with session token", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-access-key", "SESSION_TOKEN": "fake-session-token" } }`, expectError: false, }, { name: "valid awsRole", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole" } }`, expectError: false, }, { name: "valid awsRole with all fields", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole", "roleSessionName": "my-session", "duration": 3600, "webIdentityToken": "fake-web-identity-token" } }`, expectError: false, }, { name: "valid envs", input: `{ "envs": { "MY_VAR": "my-value", "ANOTHER_VAR": "another-value" } }`, expectError: false, }, { name: "valid combined response", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-access-key" }, "envs": { "CUSTOM_VAR": "custom-value" } }`, expectError: false, }, { name: "invalid awsCredentials missing ACCESS_KEY_ID", input: `{ "awsCredentials": { "SECRET_ACCESS_KEY": "fake-secret-access-key" } }`, expectError: true, errorCount: 1, }, { name: "invalid awsCredentials missing SECRET_ACCESS_KEY", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id" } }`, expectError: true, errorCount: 1, }, { name: "invalid awsRole missing roleARN", input: `{ "awsRole": { "roleSessionName": "my-session" } }`, expectError: true, errorCount: 1, }, { name: "invalid additional property at root", input: `{ "unknownField": "value" }`, expectError: true, errorCount: 1, }, { name: "invalid additional property in awsCredentials", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-access-key", "unknownField": "value" } }`, expectError: true, errorCount: 1, }, { name: "invalid duration negative", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole", "duration": -1 } }`, expectError: true, errorCount: 1, }, { name: "invalid json", input: `{invalid`, expectError: true, }, { name: "awsCredentials with envs", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-access-key", "SESSION_TOKEN": "session-token-value" }, "envs": { "TF_VAR_foo": "bar" } }`, expectError: false, }, { name: "awsRole with webIdentityToken", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/OIDCRole", "webIdentityToken": "fake-web-identity-token" } }`, expectError: false, }, { name: "envs only", input: `{ "envs": { "AWS_ACCESS_KEY_ID": "fake-access-key", "AWS_SECRET_ACCESS_KEY": "fake-secret-key", "AWS_SESSION_TOKEN": "fake-session-token" } }`, expectError: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() err := externalcmd.ValidateResponse([]byte(tc.input)) if !tc.expectError { require.NoError(t, err) return } require.Error(t, err) if tc.errorCount > 0 { var schemaErr *externalcmd.SchemaValidationError require.ErrorAs(t, err, &schemaErr) assert.Len(t, schemaErr.Errors, tc.errorCount) } }) } } func TestSchemaValidationError_Error(t *testing.T) { t.Parallel() err := &externalcmd.SchemaValidationError{ Errors: []string{"error1", "error2"}, } assert.Contains(t, err.Error(), "2 error(s)") assert.Contains(t, err.Error(), "error1") assert.Contains(t, err.Error(), "error2") } // TestValidateResponse_NoSensitiveDataInErrors verifies that validation error messages // do not leak sensitive credential values to users. func TestValidateResponse_NoSensitiveDataInErrors(t *testing.T) { t.Parallel() // These are fake sensitive values that should NEVER appear in error messages sensitiveValues := []string{ "fake-access-key-id", "fake-secret-key", "fake-session-token", "fake-web-identity-token", "super-secret-env-value-12345", } tests := []struct { name string input string }{ { name: "wrong type for ACCESS_KEY_ID should not leak value", input: `{ "awsCredentials": { "ACCESS_KEY_ID": {"nested": "fake-access-key-id"}, "SECRET_ACCESS_KEY": "fake-secret-key" } }`, }, { name: "wrong type for SECRET_ACCESS_KEY should not leak value", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": ["fake-secret-key"] } }`, }, { name: "malformed SECRET_ACCESS_KEY should not leak value", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": ["fake-secret-key } }`, }, { name: "wrong type for SESSION_TOKEN should not leak value", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-key", "SESSION_TOKEN": {"token": "fake-session-token"} } }`, }, { name: "wrong type for webIdentityToken should not leak value", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole", "webIdentityToken": ["fake-web-identity-token"] } }`, }, { name: "additional property with sensitive value should not leak", input: `{ "awsCredentials": { "ACCESS_KEY_ID": "fake-access-key-id", "SECRET_ACCESS_KEY": "fake-secret-key", "SUPER_SECRET_FIELD": "super-secret-env-value-12345" } }`, }, { name: "additional property at root with sensitive value should not leak", input: `{ "secretField": "super-secret-env-value-12345" }`, }, { name: "wrong type for envs value should not leak", input: `{ "envs": { "SECRET_VAR": {"secret": "super-secret-env-value-12345"} } }`, }, { name: "wrong type for duration with credentials present should not leak credentials", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole", "webIdentityToken": "fake-web-identity-token", "duration": "not-a-number" } }`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() err := externalcmd.ValidateResponse([]byte(tc.input)) require.Error(t, err, "expected validation error") errMsg := err.Error() for _, sensitive := range sensitiveValues { assert.NotContains(t, errMsg, sensitive, "error message should not contain sensitive value") } }) } } // TestValidateResponse_ErrorMessagesAreDescriptive verifies that error messages // provide useful information about what went wrong without exposing values. func TestValidateResponse_ErrorMessagesAreDescriptive(t *testing.T) { t.Parallel() tests := []struct { name string input string expectedPhrases []string }{ { name: "missing required field mentions field path", input: `{ "awsCredentials": { "SECRET_ACCESS_KEY": "secret" } }`, expectedPhrases: []string{"ACCESS_KEY_ID"}, }, { name: "type error mentions expected type", input: `{ "awsRole": { "roleARN": "arn:aws:iam::123456789012:role/MyRole", "duration": "not-a-number" } }`, expectedPhrases: []string{"duration"}, }, { name: "additional property error mentions the property name but not value", input: `{ "unknownField": "secret-value" }`, expectedPhrases: []string{"unknownField"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() err := externalcmd.ValidateResponse([]byte(tc.input)) require.Error(t, err) errMsg := err.Error() for _, phrase := range tc.expectedPhrases { assert.Contains( t, errMsg, phrase, "error message should mention the field/property name for debugging", ) } }) } } ================================================ FILE: internal/runner/run/creds/providers/provider.go ================================================ // Package providers defines the interface for a provider. package providers import ( "context" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( AWSCredentials CredentialsName = "AWS" ) type CredentialsName string type Credentials struct { Envs map[string]string Name CredentialsName } type Provider interface { // Name returns the name of the provider. Name() string // GetCredentials returns a set of credentials. GetCredentials(ctx context.Context, l log.Logger) (*Credentials, error) } ================================================ FILE: internal/runner/run/debug.go ================================================ package run import ( "encoding/json" "fmt" "os" "path/filepath" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" ) const TerragruntTFVarsFile = "terragrunt-debug.tfvars.json" const defaultPermissions = int(0600) // WriteTerragruntDebugFile will create a tfvars file that can be used to invoke the tofu/terraform module in the same way // that terragrunt invokes the module, so that users can debug issues with the terragrunt config. func WriteTerragruntDebugFile(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { l.Infof( "Debug mode requested: generating debug file %s in working dir %s", TerragruntTFVarsFile, opts.WorkingDir, ) required, optional, err := tf.ModuleVariables(opts.WorkingDir) if err != nil { return err } variables := slices.Concat(required, optional) tofuImpl := "tofu" if opts.TofuImplementation != "" { tofuImpl = string(opts.TofuImplementation) } l.Debugf("The following variables were detected in the %s module:", tofuImpl) l.Debugf("%v", variables) fileContents, err := terragruntDebugFileContents(l, opts, cfg, variables) if err != nil { return err } configFolder := filepath.Dir(opts.TerragruntConfigPath) fileName := filepath.Join(configFolder, TerragruntTFVarsFile) if err := os.WriteFile(fileName, fileContents, os.FileMode(defaultPermissions)); err != nil { return errors.New(err) } l.Debugf("Variables passed to %s are located in \"%s\"", tofuImpl, fileName) l.Debugf("Run this command to replicate how %s was invoked:", tofuImpl) l.Debugf( "\t%s -chdir=\"%s\" %s -var-file=\"%s\" ", tofuImpl, opts.WorkingDir, strings.Join(opts.TerraformCliArgs.Slice(), " "), fileName, ) return nil } // terragruntDebugFileContents will return a tfvars file in json format of all the terragrunt rendered variables values // that should be set to invoke the tofu/terraform module in the same way as terragrunt. Note that this will only include the // values of variables that are actually defined in the module. func terragruntDebugFileContents( l log.Logger, opts *Options, cfg *runcfg.RunConfig, moduleVariables []string, ) ([]byte, error) { envVars := map[string]string{} if opts.Env != nil { envVars = opts.Env } jsonValuesByKey := make(map[string]any) for varName, varValue := range cfg.Inputs { nameAsEnvVar := fmt.Sprintf(tf.EnvNameTFVarFmt, varName) _, varIsInEnv := envVars[nameAsEnvVar] varIsDefined := slices.Contains(moduleVariables, varName) // Only add to the file if the explicit env var does NOT exist and the variable is defined in the module. // We must do this in order to avoid overriding the env var when the user follows up with a direct invocation to // tofu/terraform using this file (due to the order in which tofu/terraform resolves config sources). switch { case !varIsInEnv && varIsDefined: jsonValuesByKey[varName] = varValue case varIsInEnv: l.Debugf( "WARN: The variable %s was omitted from the debug file because the env var %s is already set.", varName, nameAsEnvVar, ) case !varIsDefined: l.Debugf( "WARN: The variable %s was omitted because it is not defined in the OpenTofu/Terraform module.", varName, ) } } jsonContent, err := json.MarshalIndent(jsonValuesByKey, "", " ") if err != nil { return nil, errors.New(err) } return jsonContent, nil } ================================================ FILE: internal/runner/run/download_source.go ================================================ package run import ( "context" "fmt" "os" "path/filepath" "reflect" "slices" "strings" "github.com/hashicorp/go-getter" getterv2 "github.com/hashicorp/go-getter/v2" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // ModuleManifestName is the manifest for files copied from terragrunt module folder (i.e., the folder that contains the current terragrunt.hcl). const ( ModuleManifestName = ".terragrunt-module-manifest" // ModuleInitRequiredFile is a file to indicate that init should be executed. ModuleInitRequiredFile = ".terragrunt-init-required" tfLintConfig = ".tflint.hcl" fileURIScheme = "file://" ) // DownloadTerraformSource downloads the given source URL, which should use Terraform's module source syntax, // into a temporary folder, then: // 1. Check if module directory exists in temporary folder // 2. Copy the contents of opts.WorkingDir into the temporary folder. // 3. Set opts.WorkingDir to the temporary folder. // // See the NewTerraformSource method for how we determine the temporary folder so we can reuse it across multiple // runs of Terragrunt to avoid downloading everything from scratch every time. func DownloadTerraformSource( ctx context.Context, l log.Logger, source string, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) (*Options, error) { walkWithSymlinks := opts.Experiments.Evaluate(experiment.Symlinks) terraformSource, err := tf.NewSource(l, source, opts.DownloadDir, opts.WorkingDir, walkWithSymlinks) if err != nil { return nil, err } if err = DownloadTerraformSourceIfNecessary(ctx, l, terraformSource, opts, cfg, r); err != nil { return nil, err } l.Debugf( "Copying files from %s into %s", util.RelPathForLog(opts.WorkingDir, opts.WorkingDir, opts.Writers.LogShowAbsPaths), util.RelPathForLog(opts.RootWorkingDir, terraformSource.WorkingDir, opts.Writers.LogShowAbsPaths), ) // Always include the .tflint.hcl file, if it exists includeInCopy := slices.Concat(cfg.Terraform.IncludeInCopy, []string{tfLintConfig}) err = util.CopyFolderContents( l, opts.WorkingDir, terraformSource.WorkingDir, ModuleManifestName, includeInCopy, cfg.Terraform.ExcludeFromCopy, ) if err != nil { return nil, err } l, updatedOpts, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return nil, err } l.Debugf( "Setting working directory to %s", util.RelPathForLog( opts.RootWorkingDir, terraformSource.WorkingDir, opts.Writers.LogShowAbsPaths, ), ) updatedOpts.WorkingDir = terraformSource.WorkingDir return updatedOpts, nil } // DownloadTerraformSourceIfNecessary downloads the specified TerraformSource if the latest code hasn't already been downloaded. func DownloadTerraformSourceIfNecessary( ctx context.Context, l log.Logger, terraformSource *tf.Source, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) error { if opts.SourceUpdate { l.Debugf("The --source-update flag is set, so deleting the temporary folder %s before downloading source.", terraformSource.DownloadDir) if err := os.RemoveAll(terraformSource.DownloadDir); err != nil { return errors.New(err) } } else { alreadyLatest, err := AlreadyHaveLatestCode(l, terraformSource, opts) if err != nil { return err } if alreadyLatest { if err := ValidateWorkingDir(terraformSource); err != nil { return err } l.Debugf( "%s files in %s are up to date. Will not download again.", opts.TofuImplementation, util.RelPathForLog( opts.RootWorkingDir, terraformSource.WorkingDir, opts.Writers.LogShowAbsPaths, ), ) return nil } } var previousVersion = "" // read previous source version // https://github.com/gruntwork-io/terragrunt/issues/1921 if util.FileExists(terraformSource.VersionFile) { var err error previousVersion, err = readVersionFile(terraformSource) if err != nil { return err } } // When downloading source, we need to process any hooks waiting on `init-from-module`. Therefore, we clone the // options struct, set the command to the value the hooks are expecting, and run the download action surrounded by // before and after hooks (if any). l, optsForDownload, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return err } optsForDownload.TerraformCommand = tf.CommandNameInitFromModule downloadErr := RunActionWithHooks( ctx, l, "download source", optsForDownload, cfg, r, func(childCtx context.Context) error { return downloadSource(childCtx, l, terraformSource, opts, cfg, r) }, ) if downloadErr != nil { return DownloadingTerraformSourceErr{ErrMsg: downloadErr, URL: terraformSource.CanonicalSourceURL.String()} } if err := terraformSource.WriteVersionFile(l); err != nil { return err } if err := ValidateWorkingDir(terraformSource); err != nil { return err } currentVersion, err := terraformSource.EncodeSourceVersion(l) // if source versions are different or calculating version failed, create file to run init // https://github.com/gruntwork-io/terragrunt/issues/1921 if (previousVersion != "" && previousVersion != currentVersion) || err != nil { l.Debugf("Requesting re-init, source version has changed from %s to %s recently.", previousVersion, currentVersion) initFile := filepath.Join(terraformSource.WorkingDir, ModuleInitRequiredFile) f, createErr := os.Create(initFile) if createErr != nil { return createErr } defer f.Close() } return nil } // AlreadyHaveLatestCode returns true if the specified TerraformSource, of the exact same version, has already been downloaded into the // DownloadFolder. This helps avoid downloading the same code multiple times. Note that if the TerraformSource points // to a local file path, a hash will be generated from the contents of the source dir. See the ProcessTerraformSource method for more info. func AlreadyHaveLatestCode(l log.Logger, terraformSource *tf.Source, opts *Options) (bool, error) { if !util.FileExists(terraformSource.DownloadDir) || !util.FileExists(terraformSource.WorkingDir) || !util.FileExists(terraformSource.VersionFile) { return false, nil } hasFiles, err := util.DirContainsTFFiles(terraformSource.WorkingDir) if err != nil { return false, errors.New(err) } if !hasFiles { l.Debugf("Working dir %s exists but contains no Terraform or OpenTofu files, so assuming code needs to be downloaded again.", terraformSource.WorkingDir) return false, nil } currentVersion, err := terraformSource.EncodeSourceVersion(l) // If we fail to calculate the source version (e.g. because walking the // directory tree failed) use a random version instead, bypassing the cache. if err != nil { currentVersion, err = util.GenerateRandomSha256() if err != nil { return false, err } } previousVersion, err := readVersionFile(terraformSource) if err != nil { return false, err } return previousVersion == currentVersion, nil } // Return the version number stored in the DownloadDir. This version number can be used to check if the Terraform code // that has already been downloaded is the same as the version the user is currently requesting. The version number is // calculated using the encodeSourceVersion method. func readVersionFile(terraformSource *tf.Source) (string, error) { return util.ReadFileAsString(terraformSource.VersionFile) } // UpdateGetters returns the customized go-getter interfaces that Terragrunt relies on. Specifically: // - Local file path getter is updated to copy the files instead of creating symlinks, which is what go-getter defaults // to. // - Include the customized getter for fetching sources from the Terraform Registry. // // This creates a closure that returns a function so that we have access to the terragrunt configuration, which is // necessary for customizing the behavior of the file getter. func UpdateGetters(l log.Logger, opts *Options, cfg *runcfg.RunConfig) func(*getter.Client) error { return func(client *getter.Client) error { // We iterate over the global getter.Getters map and clone each getter // to avoid race conditions. The global map contains shared getter // instances, and when SetClient is called on them from multiple // goroutines, it causes data races. Cloning via dereference ensures // each client has its own getter state, while automatically picking // up any new getter types registered by go-getter. client.Getters = make(map[string]getter.Getter, len(getter.Getters)) for name, g := range getter.Getters { v := reflect.ValueOf(g).Elem() clone := reflect.New(v.Type()) clone.Elem().Set(v) client.Getters[name] = clone.Interface().(getter.Getter) } // Override with Terragrunt-specific customizations client.Getters["file"] = &FileCopyGetter{ Logger: l, IncludeInCopy: cfg.Terraform.IncludeInCopy, ExcludeFromCopy: cfg.Terraform.ExcludeFromCopy, } client.Getters["http"] = &getter.HttpGetter{Netrc: true} client.Getters["https"] = &getter.HttpGetter{Netrc: true} // Load in custom getters that are only supported in Terragrunt client.Getters["tfr"] = &tf.RegistryGetter{ TofuImplementation: opts.TofuImplementation, } return nil } } // preserveSymlinksOption is a custom client option that ensures DisableSymlinks // setting is preserved during git operations func preserveSymlinksOption() getter.ClientOption { return func(c *getter.Client) error { // Create a custom git getter that preserves symlink settings if c.Getters != nil { if _, exists := c.Getters["git"]; exists { // Replace with a wrapper that preserves symlink settings. // We create a fresh GitGetter instance instead of wrapping the // existing one to avoid race conditions when multiple goroutines // share the same getter from the global getter.Getters map. c.Getters["git"] = &symlinkPreservingGitGetter{ original: &getter.GitGetter{}, client: c, } } } // Ensure DisableSymlinks is set to false c.DisableSymlinks = false return nil } } // Download the code from the Canonical Source URL into the Download Folder using the go-getter library func downloadSource( ctx context.Context, l log.Logger, src *tf.Source, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) error { canonicalSourceURL := src.CanonicalSourceURL.String() // Since we convert abs paths to rel in logs, `file://../../path/to/dir` doesn't look good, // so it's better to get rid of it. canonicalSourceURL = strings.TrimPrefix(canonicalSourceURL, fileURIScheme) l.Infof( "Downloading Terraform configurations from %s into %s", util.RelPathForLog(opts.RootWorkingDir, canonicalSourceURL, opts.Writers.LogShowAbsPaths), util.RelPathForLog(opts.RootWorkingDir, src.DownloadDir, opts.Writers.LogShowAbsPaths)) allowCAS := opts.Experiments.Evaluate(experiment.CAS) isLocalSource := tf.IsLocalSource(src.CanonicalSourceURL) if allowCAS && !isLocalSource { l.Debugf("CAS experiment enabled: attempting to use Content Addressable Storage for source: %s", canonicalSourceURL) c, err := cas.New(cas.Options{}) if err != nil { l.Warnf("Failed to initialize CAS: %v. Falling back to standard getter.", err) } else { cloneOpts := cas.CloneOptions{ Dir: src.DownloadDir, IncludedGitFiles: []string{"HEAD", "config"}, } casGetter := cas.NewCASGetter(l, c, &cloneOpts) // Use go-getter v2 Client to properly process the Request client := getterv2.Client{ Getters: []getterv2.Getter{casGetter}, } // Set Pwd to the working directory so go-getter v2 can resolve relative paths req := &getterv2.Request{ Src: src.CanonicalSourceURL.String(), Dst: src.DownloadDir, Pwd: opts.WorkingDir, } if _, casErr := client.Get(ctx, req); casErr == nil { l.Debugf("Successfully downloaded source using CAS: %s", canonicalSourceURL) return nil } else { l.Warnf("CAS download failed: %v. Falling back to standard getter.", casErr) } } } // Fallback to standard go-getter return opts.RunWithErrorHandling(ctx, l, r, func() error { return getter.GetAny(src.DownloadDir, src.CanonicalSourceURL.String(), UpdateGetters(l, opts, cfg), preserveSymlinksOption()) }) } // ValidateWorkingDir checks if working terraformSource.WorkingDir exists and is a directory func ValidateWorkingDir(terraformSource *tf.Source) error { workingLocalDir := strings.ReplaceAll(terraformSource.WorkingDir, terraformSource.DownloadDir+filepath.FromSlash("/"), "") if util.IsFile(terraformSource.WorkingDir) { return WorkingDirNotDir{Dir: workingLocalDir, Source: terraformSource.CanonicalSourceURL.String()} } if !util.IsDir(terraformSource.WorkingDir) { return WorkingDirNotFound{Dir: workingLocalDir, Source: terraformSource.CanonicalSourceURL.String()} } return nil } type WorkingDirNotFound struct { Source string Dir string } func (err WorkingDirNotFound) Error() string { return fmt.Sprintf("Working dir %s from source %s does not exist", err.Dir, err.Source) } type WorkingDirNotDir struct { Source string Dir string } func (err WorkingDirNotDir) Error() string { return fmt.Sprintf("Valid working dir %s from source %s", err.Dir, err.Source) } type DownloadingTerraformSourceErr struct { ErrMsg error URL string } func (err DownloadingTerraformSourceErr) Error() string { return fmt.Sprintf("downloading source url %s\n%v", err.URL, err.ErrMsg) } ================================================ FILE: internal/runner/run/download_source_test.go ================================================ package run_test import ( "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/gruntwork-io/go-commons/env" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/hashicorp/go-getter" ) func TestAlreadyHaveLatestCodeLocalFilePathWithNoModifiedFiles(t *testing.T) { t.Parallel() canonicalURL := "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world-local-hash") downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/download-dir-version-file-local-hash", downloadDir) testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) // Write out a version file so we can test a cache hit terraformSource, _, _, err := createConfig(t, canonicalURL, downloadDir, false) if err != nil { t.Fatal(err) } err = terraformSource.WriteVersionFile(logger.CreateLogger()) if err != nil { t.Fatal(err) } testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, true) } func TestAlreadyHaveLatestCodeLocalFilePathHashingFailure(t *testing.T) { t.Parallel() fixturePath := absPath(t, "../../../test/fixtures/download-source/hello-world-local-hash-failed") canonicalURL := "file://" + fixturePath downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-local-hash-failed", downloadDir) fileInfo, err := os.Stat(fixturePath) if err != nil { t.Fatal(err) } err = os.Chmod(fixturePath, 0000) if err != nil { t.Fatal(err) } testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) err = os.Chmod(fixturePath, fileInfo.Mode()) if err != nil { t.Fatal(err) } } func TestAlreadyHaveLatestCodeLocalFilePathWithHashChanged(t *testing.T) { t.Parallel() canonicalURL := "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world-local-hash") downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/download-dir-version-file-local-hash", downloadDir) f, err := os.OpenFile(downloadDir+"/version-file.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { t.Fatal(err) } defer f.Close() // Modify content of file to simulate change fmt.Fprintln(f, "CHANGED") testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeLocalFilePath(t *testing.T) { t.Parallel() canonicalURL := "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world") downloadDir := "does-not-exist" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirDoesNotExist(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com" downloadDir := "does-not-exist" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirExistsNoVersionNoVersionFile(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com" downloadDir := "../../../test/fixtures/download-source/download-dir-empty" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirExistsNoVersionWithVersionFile(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com" downloadDir := "../../../test/fixtures/download-source/download-dir-version-file-no-query" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, true) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirExistsWithVersionNoVersionFile(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com?ref=v0.0.1" downloadDir := "../../../test/fixtures/download-source/download-dir-empty" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirExistsWithVersionAndVersionFile(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com?ref=v0.0.1" downloadDir := "../../../test/fixtures/download-source/download-dir-version-file" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, false) } func TestAlreadyHaveLatestCodeRemoteFilePathDownloadDirExistsWithVersionAndVersionFileAndTfCode(t *testing.T) { t.Parallel() canonicalURL := "http://www.some-url.com?ref=v0.0.1" downloadDir := "../../../test/fixtures/download-source/download-dir-version-file-tf-code" testAlreadyHaveLatestCode(t, canonicalURL, downloadDir, true) } func TestDownloadTerraformSourceIfNecessaryLocalDirToEmptyDir(t *testing.T) { t.Parallel() canonicalURL := "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world") downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World", false) } func TestDownloadTerraformSourceIfNecessaryLocalDirToAlreadyDownloadedDir(t *testing.T) { t.Parallel() canonicalURL := "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world") downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-2", downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World", false) } func TestDownloadTerraformSourceIfNecessaryRemoteUrlToEmptyDir(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World", false) } func TestDownloadTerraformSourceIfNecessaryRemoteUrlToAlreadyDownloadedDir(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-2", downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World 2", false) } func TestDownloadTerraformSourceIfNecessaryRemoteUrlToAlreadyDownloadedDirDifferentVersion(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world?ref=v0.83.2" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-2", downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World", true) } func TestDownloadTerraformSourceIfNecessaryRemoteUrlToAlreadyDownloadedDirSameVersion(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world-version-remote?ref=v0.83.2" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-version-remote", downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, false, "# Hello, World version remote", false) } func TestDownloadTerraformSourceIfNecessaryRemoteUrlOverrideSource(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world?ref=v0.83.2" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-version-remote", downloadDir) testDownloadTerraformSourceIfNecessary(t, canonicalURL, downloadDir, true, "# Hello, World", false) } func TestDownloadTerraformSourceIfNecessaryInvalidTerraformSource(t *testing.T) { t.Parallel() canonicalURL := "github.com/totallyfakedoesnotexist/notreal.git//foo?ref=v1.2.3" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-version-remote", downloadDir) terraformSource, opts, cfg, err := createConfig(t, canonicalURL, downloadDir, false) require.NoError(t, err) err = run.DownloadTerraformSourceIfNecessary( t.Context(), logger.CreateLogger(), terraformSource, configbridge.NewRunOptions(opts), cfg, report.NewReport(), ) require.Error(t, err) var downloadingTerraformSourceErr run.DownloadingTerraformSourceErr ok := errors.As(err, &downloadingTerraformSourceErr) assert.True(t, ok) } func TestInvalidModulePath(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world-version-remote/non-existent-path?ref=v0.83.2" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-version-remote", downloadDir) terraformSource, _, _, err := createConfig(t, canonicalURL, downloadDir, false) require.NoError(t, err) terraformSource.WorkingDir += "/not-existing-path" err = run.ValidateWorkingDir(terraformSource) require.Error(t, err) var workingDirNotFound run.WorkingDirNotFound ok := errors.As(err, &workingDirNotFound) assert.True(t, ok) } func TestDownloadInvalidPathToFilePath(t *testing.T) { t.Parallel() canonicalURL := "github.com/gruntwork-io/terragrunt//test/fixtures/download-source/hello-world/main.tf?ref=v0.83.2" downloadDir := helpers.TmpDirWOSymlinks(t) defer os.Remove(downloadDir) copyFolder(t, "../../../test/fixtures/download-source/hello-world-version-remote", downloadDir) terraformSource, _, _, err := createConfig(t, canonicalURL, downloadDir, false) require.NoError(t, err) terraformSource.WorkingDir += "/main.tf" err = run.ValidateWorkingDir(terraformSource) require.Error(t, err) var workingDirNotDir run.WorkingDirNotDir ok := errors.As(err, &workingDirNotDir) assert.True(t, ok) } // The test cases are run sequentially because they depend on each other. // //nolint:tparallel func TestDownloadTerraformSourceFromLocalFolderWithManifest(t *testing.T) { t.Parallel() downloadDir := helpers.TmpDirWOSymlinks(t) t.Cleanup(func() { os.RemoveAll(downloadDir) }) // used to test if an empty folder gets copied testDir := helpers.TmpDirWOSymlinks(t) require.NoError(t, os.Mkdir(path.Join(testDir, "sub2"), 0700)) t.Cleanup(func() { os.Remove(testDir) }) testCases := []struct { comp assert.Comparison name string sourceURL string }{ { name: "test-stale-file-exists", sourceURL: "../../../test/fixtures/manifest/version-1", comp: func() bool { return util.FileExists(filepath.Join(downloadDir, "stale.tf")) }, }, { name: "test-stale-file-doesnt-exist-after-source-update", sourceURL: "../../../test/fixtures/manifest/version-2", comp: func() bool { return !util.FileExists(filepath.Join(downloadDir, "stale.tf")) }, }, { name: "test-tffile-exists-in-subfolder", sourceURL: "../../../test/fixtures/manifest/version-3-subfolder", comp: func() bool { return util.FileExists(filepath.Join(downloadDir, "sub", "main.tf")) }, }, { name: "test-tffile-doesnt-exist-in-subfolder", sourceURL: "../../../test/fixtures/manifest/version-4-subfolder-empty", comp: func() bool { return !util.FileExists(filepath.Join(downloadDir, "sub", "main.tf")) }, }, { name: "test-empty-folder-gets-copied", sourceURL: testDir, comp: func() bool { return util.FileExists(filepath.Join(downloadDir, "sub2")) }, }, { name: "test-empty-folder-gets-populated", sourceURL: "../../../test/fixtures/manifest/version-5-not-empty-subfolder", comp: func() bool { return util.FileExists(filepath.Join(downloadDir, "sub2", "main.tf")) }, }, } // The test cases are run sequentially because they depend on each other. // //nolint:paralleltest for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { copyFolder(t, tc.sourceURL, downloadDir) assert.Condition(t, tc.comp) }) } } func testDownloadTerraformSourceIfNecessary( t *testing.T, canonicalURL string, downloadDir string, sourceUpdate bool, expectedFileContents string, requireInitFile bool, ) { t.Helper() terraformSource, opts, cfg, err := createConfig( t, canonicalURL, downloadDir, sourceUpdate, ) require.NoError(t, err) err = run.DownloadTerraformSourceIfNecessary( t.Context(), logger.CreateLogger(), terraformSource, configbridge.NewRunOptions(opts), cfg, report.NewReport(), ) require.NoError(t, err, "For terraform source %v: %v", terraformSource, err) expectedFilePath := filepath.Join(downloadDir, "main.tf") if assert.True(t, util.FileExists(expectedFilePath), "For terraform source %v", terraformSource) { actualFileContents := readFile(t, expectedFilePath) assert.Equal(t, expectedFileContents, actualFileContents, "For terraform source %v", terraformSource) } if requireInitFile { existsInitFile := util.FileExists(filepath.Join(terraformSource.WorkingDir, run.ModuleInitRequiredFile)) require.True(t, existsInitFile) } } func createConfig( t *testing.T, canonicalURL string, downloadDir string, sourceUpdate bool, ) (*tf.Source, *options.TerragruntOptions, *runcfg.RunConfig, error) { t.Helper() logger := logger.CreateLogger() logger.SetOptions(log.WithOutput(io.Discard)) terraformSource := &tf.Source{ CanonicalSourceURL: parseURL(t, canonicalURL), DownloadDir: downloadDir, WorkingDir: downloadDir, VersionFile: filepath.Join(downloadDir, "version-file.txt"), } opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) opts.SourceUpdate = sourceUpdate opts.Env = env.Parse(os.Environ()) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } _, ver, impl, err := run.PopulateTFVersion(t.Context(), logger, opts.WorkingDir, opts.VersionManagerFileName, configbridge.TFRunOptsFromOpts(opts)) require.NoError(t, err) opts.TerraformVersion = ver opts.TofuImplementation = impl return terraformSource, opts, cfg, err } func testAlreadyHaveLatestCode(t *testing.T, canonicalURL string, downloadDir string, expected bool) { t.Helper() logger := logger.CreateLogger() logger.SetOptions(log.WithOutput(io.Discard)) terraformSource := &tf.Source{ CanonicalSourceURL: parseURL(t, canonicalURL), DownloadDir: downloadDir, WorkingDir: downloadDir, VersionFile: filepath.Join(downloadDir, "version-file.txt"), } opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) actual, err := run.AlreadyHaveLatestCode(logger, terraformSource, configbridge.NewRunOptions(opts)) require.NoError(t, err) assert.Equal(t, expected, actual, "For terraform source %v", terraformSource) } func absPath(t *testing.T, path string) string { t.Helper() if filepath.IsAbs(path) { return filepath.Clean(path) } absPath, err := filepath.Abs(path) require.NoError(t, err) return filepath.Clean(absPath) } func parseURL(t *testing.T, str string) *url.URL { t.Helper() // URLs should have only forward slashes, whereas on Windows, the file paths may be backslashes rawURL := strings.Join(strings.Split(str, string(filepath.Separator)), "/") parsed, err := url.Parse(rawURL) if err != nil { t.Fatal(err) } return parsed } func readFile(t *testing.T, path string) string { t.Helper() contents, err := util.ReadFileAsString(path) if err != nil { t.Fatal(err) } return contents } func copyFolder(t *testing.T, src string, dest string) { t.Helper() l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) err := util.CopyFolderContents( l, filepath.FromSlash(src), filepath.FromSlash(dest), ".terragrunt-test", nil, nil, ) require.NoError(t, err) } // TestUpdateGettersExcludeFromCopy verifies the correct behavior of updateGetters with ExcludeFromCopy configuration func TestUpdateGettersExcludeFromCopy(t *testing.T) { t.Parallel() testCases := []struct { name string cfg *runcfg.RunConfig expectedExcludeFiles []string }{ { name: "Nil ExcludeFromCopy", cfg: &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExcludeFromCopy: []string{}, }, }, expectedExcludeFiles: []string{}, }, { name: "Non-Nil ExcludeFromCopy", cfg: &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExcludeFromCopy: []string{"*.tfstate", "excluded_dir/"}, }, }, expectedExcludeFiles: []string{"*.tfstate", "excluded_dir/"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("./test") require.NoError(t, err) client := &getter.Client{} // Call updateGetters updateGettersFunc := run.UpdateGetters(logger.CreateLogger(), configbridge.NewRunOptions(terragruntOptions), tc.cfg) err = updateGettersFunc(client) require.NoError(t, err) // Find the file getter fileGetter, ok := client.Getters["file"].(*run.FileCopyGetter) require.True(t, ok, "File getter should be of type FileCopyGetter") // Verify ExcludeFromCopy assert.Equal( t, tc.expectedExcludeFiles, fileGetter.ExcludeFromCopy, "ExcludeFromCopy should match expected value", ) }) } } // TestUpdateGettersHTTPNetrc verifies that HTTP/HTTPS getters have Netrc enabled // for authentication via ~/.netrc files. func TestUpdateGettersHTTPNetrc(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("./test") require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{}, } client := &getter.Client{} updateGettersFunc := run.UpdateGetters(logger.CreateLogger(), configbridge.NewRunOptions(terragruntOptions), cfg) err = updateGettersFunc(client) require.NoError(t, err) // Verify HTTP getter has Netrc enabled httpGetter, ok := client.Getters["http"].(*getter.HttpGetter) require.True(t, ok, "HTTP getter should be of type HttpGetter") assert.True(t, httpGetter.Netrc, "HTTP getter should have Netrc enabled for ~/.netrc authentication") // Verify HTTPS getter has Netrc enabled httpsGetter, ok := client.Getters["https"].(*getter.HttpGetter) require.True(t, ok, "HTTPS getter should be of type HttpGetter") assert.True(t, httpsGetter.Netrc, "HTTPS getter should have Netrc enabled for ~/.netrc authentication") } // TestUpdateGettersIncludesAllGlobalGetters verifies that every scheme registered in the global // getter.Getters map is present in client.Getters after calling UpdateGetters. This guards against // regressions where the reflect-based approach might silently fail to create an instance. func TestUpdateGettersIncludesAllGlobalGetters(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("./test") require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{}, } client := &getter.Client{} updateGettersFunc := run.UpdateGetters(logger.CreateLogger(), configbridge.NewRunOptions(terragruntOptions), cfg) err = updateGettersFunc(client) require.NoError(t, err) // Every scheme from the global getter.Getters map must be present for scheme := range getter.Getters { assert.Contains(t, client.Getters, scheme, "client.Getters should contain the %q scheme from the global getter.Getters map", scheme) } // Terragrunt-specific getters must also be present assert.Contains(t, client.Getters, "tfr", "client.Getters should contain the Terragrunt registry getter") } // TestDownloadWithNoSourceCreatesCache tests that when sourceURL is "." (no source specified), // DownloadTerraformSource creates cache and copies files from the working directory. // This tests the behavior when terragrunt.hcl doesn't have a terraform { source = "..." } block. func TestDownloadWithNoSourceCreatesCache(t *testing.T) { t.Parallel() // Create a temp directory to act as the source/working directory sourceDir := helpers.TmpDirWOSymlinks(t) defer os.RemoveAll(sourceDir) // Create a simple terraform file in the source directory mainTfContent := "# Test file for no-source cache creation\n" err := os.WriteFile(filepath.Join(sourceDir, "main.tf"), []byte(mainTfContent), 0644) require.NoError(t, err) // Create the download directory where cache will be created downloadDir := helpers.TmpDirWOSymlinks(t) defer os.RemoveAll(downloadDir) opts, err := options.NewTerragruntOptionsForTest(filepath.Join(sourceDir, "terragrunt.hcl")) require.NoError(t, err) opts.WorkingDir = sourceDir opts.DownloadDir = downloadDir opts.Experiments = experiment.NewExperiments() cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) r := report.NewReport() // sourceURL "." represents the current directory (no terraform.source specified) updatedOpts, err := run.DownloadTerraformSource(t.Context(), l, ".", configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) // Verify that the working directory was changed to the cache directory (inside downloadDir) assert.NotEqual(t, sourceDir, updatedOpts.WorkingDir, "Working dir should be changed to cache") assert.True(t, strings.HasPrefix(updatedOpts.WorkingDir, downloadDir), "Working dir should be under download dir") // Verify that the main.tf file was copied to the cache cachedMainTf := filepath.Join(updatedOpts.WorkingDir, "main.tf") assert.FileExists(t, cachedMainTf, "main.tf should exist in cache directory") // Verify the contents were copied correctly cachedContent, err := os.ReadFile(cachedMainTf) require.NoError(t, err) assert.Equal(t, mainTfContent, string(cachedContent), "File contents should match") } // TestDownloadSourceWithCASExperimentDisabled tests that CAS is not used when the experiment is disabled func TestDownloadSourceWithCASExperimentDisabled(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) localSourcePath := absPath(t, "../../../test/fixtures/download-source/hello-world") src := &tf.Source{ CanonicalSourceURL: parseURL(t, "file://"+localSourcePath), DownloadDir: tmpDir, WorkingDir: tmpDir, VersionFile: filepath.Join(tmpDir, "version-file.txt"), } opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) // Ensure CAS experiment is not enabled opts.Experiments = experiment.NewExperiments() cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) // Mock the download source function call r := report.NewReport() err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) // Verify the file was downloaded expectedFilePath := filepath.Join(tmpDir, "main.tf") assert.FileExists(t, expectedFilePath) } // TestDownloadSourceWithCASExperimentEnabled tests that CAS is attempted when the experiment is enabled func TestDownloadSourceWithCASExperimentEnabled(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) localSourcePath := absPath(t, "../../../test/fixtures/download-source/hello-world") src := &tf.Source{ CanonicalSourceURL: parseURL(t, "file://"+localSourcePath), DownloadDir: tmpDir, WorkingDir: tmpDir, VersionFile: filepath.Join(tmpDir, "version-file.txt"), } // Create options with CAS experiment enabled opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) // Enable CAS experiment opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.CAS) require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) r := report.NewReport() err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) expectedFilePath := filepath.Join(tmpDir, "main.tf") assert.FileExists(t, expectedFilePath) } // TestDownloadSourceWithCASGitSource tests CAS functionality with a Git source func TestDownloadSourceWithCASGitSource(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) src := &tf.Source{ CanonicalSourceURL: parseURL( t, "github.com/gruntwork-io/terragrunt//test/fixtures/download/hello-world", ), DownloadDir: tmpDir, WorkingDir: tmpDir, VersionFile: filepath.Join(tmpDir, "version-file.txt"), } opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) // Enable CAS experiment opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.CAS) require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) r := report.NewReport() err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) // Verify the file was downloaded expectedFilePath := filepath.Join(tmpDir, "main.tf") assert.FileExists(t, expectedFilePath) } // TestDownloadSourceCASInitializationFailure tests the fallback behavior when CAS initialization fails func TestDownloadSourceCASInitializationFailure(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) localSourcePath := absPath(t, "../../../test/fixtures/download-source/hello-world") src := &tf.Source{ CanonicalSourceURL: parseURL(t, "file://"+localSourcePath), DownloadDir: tmpDir, WorkingDir: tmpDir, VersionFile: filepath.Join(tmpDir, "version-file.txt"), } opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) // Enable CAS experiment opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.CAS) require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) r := report.NewReport() err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) expectedFilePath := filepath.Join(tmpDir, "main.tf") assert.FileExists(t, expectedFilePath) } // TestDownloadSourceWithCASMultipleSources tests that CAS works with multiple different sources func TestDownloadSourceWithCASMultipleSources(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) opts.Env = env.Parse(os.Environ()) // Enable CAS experiment opts.Experiments = experiment.NewExperiments() err = opts.Experiments.EnableExperiment(experiment.CAS) require.NoError(t, err) cfg := &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ ExtraArgs: []runcfg.TerraformExtraArguments{}, }, } l := logger.CreateLogger() l.SetOptions(log.WithOutput(io.Discard)) r := report.NewReport() testCases := []struct { name string sourceURL string expectCAS bool }{ { name: "Local file source", sourceURL: "file://" + absPath(t, "../../../test/fixtures/download-source/hello-world"), expectCAS: false, // CAS doesn't handle file:// URLs }, { name: "HTTP source", sourceURL: "https://example.com/repo.tar.gz", expectCAS: false, // CAS doesn't handle HTTP sources }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) src := &tf.Source{ CanonicalSourceURL: parseURL(t, tc.sourceURL), DownloadDir: tmpDir, WorkingDir: tmpDir, VersionFile: filepath.Join(tmpDir, "version-file.txt"), } err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) if tc.name == "Local file source" { require.NoError(t, err) expectedFilePath := filepath.Join(tmpDir, "main.tf") assert.FileExists(t, expectedFilePath) } else { t.Logf("Source %s result: %v", tc.sourceURL, err) } }) } } // TestHTTPGetterNetrcAuthentication verifies that HTTP/HTTPS getters correctly authenticate // using ~/.netrc credentials when downloading OpenTofu/Terraform sources. // // Does not use `t.Parallel()` because we need to set the `NETRC` environment variable // to point to a temporary `~/.netrc` file for the test to pass. func TestHTTPGetterNetrcAuthentication(t *testing.T) { expectedUser := "testuser" expectedPass := "testpassword" fileContent := "# test tofu content" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok || user != expectedUser || pass != expectedPass { w.WriteHeader(http.StatusUnauthorized) return } w.Write([]byte(fileContent)) })) defer server.Close() serverURL, err := url.Parse(server.URL) require.NoError(t, err) netrcContent := fmt.Sprintf("machine %s\nlogin %s\npassword %s\n", serverURL.Host, expectedUser, expectedPass) netrcFile := filepath.Join(t.TempDir(), ".netrc") require.NoError(t, os.WriteFile(netrcFile, []byte(netrcContent), 0600)) t.Setenv("NETRC", netrcFile) opts, err := options.NewTerragruntOptionsForTest("./test") require.NoError(t, err) cfg := &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}} client := &getter.Client{ Src: server.URL + "/module.tf", Dst: filepath.Join(t.TempDir(), "module.tf"), Mode: getter.ClientModeFile, } updateFn := run.UpdateGetters(logger.CreateLogger(), configbridge.NewRunOptions(opts), cfg) require.NoError(t, updateFn(client)) require.NoError(t, client.Get()) downloaded, err := os.ReadFile(client.Dst) require.NoError(t, err) assert.Equal(t, fileContent, string(downloaded)) } ================================================ FILE: internal/runner/run/errors.go ================================================ package run import ( "fmt" ) // Custom error types type MissingCommand struct{} func (err MissingCommand) Error() string { return "Missing terraform command (Example: terragrunt run plan)" } type WrongTerraformCommand string func (name WrongTerraformCommand) Error() string { return fmt.Sprintf("Terraform has no command named %q. To see all of Terraform's top-level commands, run: terraform -help", string(name)) } type WrongTofuCommand string func (name WrongTofuCommand) Error() string { return fmt.Sprintf("OpenTofu has no command named %q. To see all of OpenTofu's top-level commands, run: tofu -help", string(name)) } type BackendNotDefined struct { ConfigPath string WorkingDir string BackendType string } func (err BackendNotDefined) Error() string { return fmt.Sprintf("Found remote_state settings in %s but no backend block in the Terraform code in %s. You must define a backend block (it can be empty!) in your Terraform code or your remote state settings will have no effect! It should look something like this:\n\nterraform {\n backend \"%s\" {}\n}\n\n", err.ConfigPath, err.WorkingDir, err.BackendType) } type NoTerraformFilesFound string func (path NoTerraformFilesFound) Error() string { return "Did not find any Terraform files (*.tf) or OpenTofu files (*.tofu) in " + string(path) } type ModuleIsProtected struct { ConfigPath string } func (err ModuleIsProtected) Error() string { return fmt.Sprintf("Unit is protected by the prevent_destroy flag in %s. Set it to false or remove it to allow destruction of the unit.", err.ConfigPath) } // Legacy retry error removed in favor of error handling via options.Errors type RunAllDisabledErr struct { command string reason string } func (err RunAllDisabledErr) Error() string { return fmt.Sprintf("%s with run --all is disabled: %s", err.command, err.reason) } ================================================ FILE: internal/runner/run/file_copy_getter.go ================================================ package run import ( "net/url" "os" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-getter" ) // SourceManifestName is the manifest for files copied from the URL specified in the terraform { source = "" } config const SourceManifestName = ".terragrunt-source-manifest" // FileCopyGetter is a custom getter.Getter implementation that uses file copying instead of symlinks. Symlinks are // faster and use less disk space, but they cause issues in Windows and with infinite loops, so we copy files/folders // instead. type FileCopyGetter struct { Logger log.Logger getter.FileGetter // List of glob paths that should be included in the copy. This can be used to override the default behavior of // Terragrunt, which will skip hidden folders. IncludeInCopy []string ExcludeFromCopy []string } // Get replaces the original FileGetter // The original FileGetter does NOT know how to do folder copying (it only does symlinks), so we provide a copy // implementation here func (g *FileCopyGetter) Get(dst string, u *url.URL) error { path := u.Path if u.RawPath != "" { path = u.RawPath } // The source path must exist and be a directory to be usable. if fi, err := os.Stat(path); err != nil { return errors.Errorf("source path error: %s", err) } else if !fi.IsDir() { return errors.Errorf("source path must be a directory") } return util.CopyFolderContents(g.Logger, path, dst, SourceManifestName, g.IncludeInCopy, g.ExcludeFromCopy) } // GetFile The original FileGetter already knows how to do file copying so long as we set the Copy flag to true, so just // delegate to it func (g *FileCopyGetter) GetFile(dst string, u *url.URL) error { underlying := &getter.FileGetter{Copy: true} if err := underlying.GetFile(dst, u); err != nil { return errors.Errorf("failed to copy file to %s: %w", dst, err) } return nil } ================================================ FILE: internal/runner/run/hook.go ================================================ package run import ( "context" "fmt" "slices" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/cloner" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tflint" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-multierror" ) const ( HookCtxTFPathEnvName = "TG_CTX_TF_PATH" HookCtxCommandEnvName = "TG_CTX_COMMAND" HookCtxHookNameEnvName = "TG_CTX_HOOK_NAME" ) // hookErrorMessage extracts command, args and output from the error // so users see WHY a hook failed, not just the exit code. func hookErrorMessage(hookName string, err error) string { var processErr util.ProcessExecutionError if !errors.As(err, &processErr) { return fmt.Sprintf("Hook %q failed to execute: %v", hookName, err) } exitCode, exitCodeErr := processErr.ExitStatus() if exitCodeErr != nil { return fmt.Sprintf("Hook %q failed to execute: %v", hookName, err) } cmd := strings.Join(append([]string{processErr.Command}, processErr.Args...), " ") output := strings.TrimSpace(processErr.Output.Stderr.String()) if output == "" { output = strings.TrimSpace(processErr.Output.Stdout.String()) } if output != "" { return fmt.Sprintf("Hook %q (command: %s) exited with non-zero exit code %d:\n%s", hookName, cmd, exitCode, output) } return fmt.Sprintf("Hook %q (command: %s) exited with non-zero exit code %d", hookName, cmd, exitCode) } func processErrorHooks( ctx context.Context, l log.Logger, hooks []runcfg.ErrorHook, opts *Options, previousExecErrors *errors.MultiError, ) error { if len(hooks) == 0 || previousExecErrors.ErrorOrNil() == nil { return nil } var errorsOccured *multierror.Error l.Debugf("Detected %d error Hooks", len(hooks)) customMultierror := multierror.Error{ Errors: previousExecErrors.WrappedErrors(), ErrorFormat: func(err []error) string { errorMessages := make([]string, 0, len(err)) for _, e := range err { errorMessage := e.Error() // Check if is process execution error and try to extract output // https://github.com/gruntwork-io/terragrunt/issues/2045 originalError := errors.Unwrap(e) if originalError != nil { var processError util.ProcessExecutionError if ok := errors.As(originalError, &processError); ok { errorMessage = fmt.Sprintf("%s\n%s", processError.Error(), processError.Output.Stdout.String()) } } errorMessages = append(errorMessages, errorMessage) } return strings.Join(errorMessages, "\n") }, } errorMessage := customMultierror.Error() for _, curHook := range hooks { if util.MatchesAny(curHook.OnErrors, errorMessage) && slices.Contains(curHook.Commands, opts.TerraformCommand) { err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "error_hook_"+curHook.Name, map[string]any{ "hook": curHook.Name, "dir": curHook.WorkingDir, }, func(ctx context.Context) error { l.Infof("Executing hook: %s", curHook.Name) actionToExecute := curHook.Execute[0] actionParams := curHook.Execute[1:] hookOpts := optsWithHookEnvs(opts, curHook.Name) _, possibleError := shell.RunCommandWithOutput( ctx, l, hookOpts.shellRunOptions(), curHook.WorkingDir, curHook.SuppressStdout, false, actionToExecute, actionParams..., ) if possibleError != nil { l.Errorf("%s", hookErrorMessage(curHook.Name, possibleError)) return possibleError } return nil }) if err != nil { errorsOccured = multierror.Append(errorsOccured, err) } } } return errorsOccured.ErrorOrNil() } // ProcessHooks processes a list of hooks, executing each one that matches the current command. func ProcessHooks( ctx context.Context, l log.Logger, hooks []runcfg.Hook, opts *Options, cfg *runcfg.RunConfig, previousExecErrors *errors.MultiError, _ *report.Report, ) error { if len(hooks) == 0 { return nil } var errorsOccured *multierror.Error l.Debugf("Detected %d Hooks", len(hooks)) for i := range hooks { curHook := &hooks[i] if !curHook.If { l.Debugf("Skipping hook: %s", curHook.Name) continue } allPreviousErrors := previousExecErrors.Append(errorsOccured) if shouldRunHook(curHook, opts, allPreviousErrors) { err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hook_"+curHook.Name, map[string]any{ "hook": curHook.Name, "dir": curHook.WorkingDir, }, func(ctx context.Context) error { return runHook(ctx, l, opts, cfg, curHook) }) if err != nil { errorsOccured = multierror.Append(errorsOccured, err) } } } return errorsOccured.ErrorOrNil() } func shouldRunHook( hook *runcfg.Hook, opts *Options, previousExecErrors *errors.MultiError, ) bool { // if there's no previous error, execute command // OR if a previous error DID happen AND we want to run anyways // then execute. // Skip execution if there was an error AND we care about errors // // resolves: https://github.com/gruntwork-io/terragrunt/issues/459 hasErrors := previousExecErrors.ErrorOrNil() != nil isCommandInHook := slices.Contains(hook.Commands, opts.TerraformCommand) return isCommandInHook && (!hasErrors || hook.RunOnError) } func runHook( ctx context.Context, l log.Logger, opts *Options, cfg *runcfg.RunConfig, curHook *runcfg.Hook, ) error { l.Infof("Executing hook: %s", curHook.Name) workingDir := curHook.WorkingDir suppressStdout := curHook.SuppressStdout actionToExecute := curHook.Execute[0] actionParams := curHook.Execute[1:] hookOpts := optsWithHookEnvs(opts, curHook.Name) if actionToExecute == "tflint" { return executeTFLint(ctx, l, opts, cfg, curHook, workingDir) } _, possibleError := shell.RunCommandWithOutput( ctx, l, hookOpts.shellRunOptions(), workingDir, suppressStdout, false, actionToExecute, actionParams..., ) if possibleError != nil { l.Errorf("%s", hookErrorMessage(curHook.Name, possibleError)) } return possibleError } func executeTFLint( ctx context.Context, l log.Logger, opts *Options, cfg *runcfg.RunConfig, curHook *runcfg.Hook, workingDir string, ) error { // fetching source code changes lock since tflint is not thread safe rawActualLock, _ := sourceChangeLocks.LoadOrStore(workingDir, &sync.Mutex{}) actualLock := rawActualLock.(*sync.Mutex) actualLock.Lock() defer actualLock.Unlock() err := tflint.RunTflintWithOpts(ctx, l, opts.tflintRunOptions(), cfg, curHook) if err != nil { l.Errorf("%s", hookErrorMessage(curHook.Name, err)) return err } return nil } func optsWithHookEnvs(opts *Options, hookName string) *Options { newOpts := *opts newOpts.Env = cloner.Clone(opts.Env) newOpts.Env[HookCtxTFPathEnvName] = opts.TFPath newOpts.Env[HookCtxCommandEnvName] = opts.TerraformCommand newOpts.Env[HookCtxHookNameEnvName] = hookName return &newOpts } ================================================ FILE: internal/runner/run/hook_internal_test.go ================================================ package run import ( "fmt" "os/exec" "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tflint" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func getExitError(t *testing.T, exitCode int) *exec.ExitError { t.Helper() cmd := exec.CommandContext(t.Context(), "sh", "-c", fmt.Sprintf("exit %d", exitCode)) err := cmd.Run() require.Error(t, err) var exitErr *exec.ExitError require.True(t, errors.As(err, &exitErr)) return exitErr } func TestHookErrorMessage_WithStderr(t *testing.T) { t.Parallel() var output util.CmdOutput output.Stderr.WriteString("resource missing required tags") err := util.ProcessExecutionError{ Err: getExitError(t, 2), Command: "tflint", Args: []string{"--config", ".tflint.hcl"}, WorkingDir: "/tmp", Output: output, } msg := hookErrorMessage("my-lint", errors.New(err)) assert.Contains(t, msg, `Hook "my-lint"`) assert.Contains(t, msg, "tflint --config .tflint.hcl") assert.Contains(t, msg, "non-zero exit code 2") assert.Contains(t, msg, "resource missing required tags") } func TestHookErrorMessage_StdoutFallback(t *testing.T) { t.Parallel() var output util.CmdOutput output.Stdout.WriteString("warning: deprecated feature") err := util.ProcessExecutionError{ Err: getExitError(t, 1), Command: "custom-lint", Args: []string{"--fix"}, WorkingDir: "/tmp", Output: output, } msg := hookErrorMessage("lint-hook", errors.New(err)) assert.Contains(t, msg, `Hook "lint-hook"`) assert.Contains(t, msg, "custom-lint --fix") assert.Contains(t, msg, "non-zero exit code 1") assert.Contains(t, msg, "warning: deprecated feature") } func TestHookErrorMessage_NoOutput(t *testing.T) { t.Parallel() err := util.ProcessExecutionError{ Err: getExitError(t, 3), Command: "check", Args: []string{"-strict"}, WorkingDir: "/tmp", } msg := hookErrorMessage("my-hook", errors.New(err)) assert.Contains(t, msg, `Hook "my-hook"`) assert.Contains(t, msg, "check -strict") assert.Contains(t, msg, "non-zero exit code 3") } func TestHookErrorMessage_TflintWrapped(t *testing.T) { t.Parallel() var output util.CmdOutput output.Stderr.WriteString("3 issue(s) found") processErr := util.ProcessExecutionError{ Err: getExitError(t, 2), Command: "tflint", Args: []string{"--config", ".tflint.hcl"}, WorkingDir: "/tmp", Output: output, } // Simulate the real tflint error chain: ErrorRunningTflint wraps ProcessExecutionError tflintErr := tflint.ErrorRunningTflint{ Args: []string{"tflint", "--config", ".tflint.hcl"}, Err: errors.New(processErr), } msg := hookErrorMessage("tflint", errors.New(tflintErr)) assert.Contains(t, msg, `Hook "tflint"`) assert.Contains(t, msg, "tflint --config .tflint.hcl") assert.Contains(t, msg, "non-zero exit code 2") assert.Contains(t, msg, "3 issue(s) found") } func TestHookErrorMessage_NonProcessError(t *testing.T) { t.Parallel() err := errors.New("exec: \"tflint\": executable file not found in $PATH") msg := hookErrorMessage("my-hook", err) assert.Equal(t, `Hook "my-hook" failed to execute: exec: "tflint": executable file not found in $PATH`, msg) } ================================================ FILE: internal/runner/run/options.go ================================================ package run import ( "context" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/puzpuzpuz/xsync/v3" "github.com/gruntwork-io/terragrunt/internal/cloner" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/tflint" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" ) const ( defaultTFDataDir = ".terraform" defaultSignalsFile = "error-signals.json" ) // Options contains the configuration needed by run.Run and its helpers. // This is a focused subset of options.TerragruntOptions. type Options struct { TerraformCliArgs *iacargs.IacArgs EngineConfig *engine.EngineConfig EngineOptions *engine.EngineOptions Errors *errorconfig.Config FeatureFlags *xsync.MapOf[string, string] Telemetry *telemetry.Options SourceMap map[string]string Env map[string]string Writers writer.Writers TFPath string TerraformCommand string TofuImplementation tfimpl.Type TerragruntConfigPath string OriginalTerragruntConfigPath string WorkingDir string DownloadDir string RootWorkingDir string OriginalTerraformCommand string Source string AuthProviderCmd string OriginalIAMRoleOptions iam.RoleOptions IAMRoleOptions iam.RoleOptions Experiments experiment.Experiments StrictControls strict.Controls MaxFoldersToCheck int AutoRetry bool Headless bool NonInteractive bool Debug bool AutoInit bool JSONLogFormat bool BackendBootstrap bool FailIfBucketCreationRequired bool DisableBucketUpdate bool SourceUpdate bool ForwardTFStdout bool } // Clone performs a deep copy of Options. func (o *Options) Clone() *Options { return cloner.Clone(o) } // CloneWithConfigPath creates a copy of Options with updated config path and working directory. func (o *Options) CloneWithConfigPath(l log.Logger, configPath string) (log.Logger, *Options, error) { newOpts := o.Clone() configPath = filepath.Clean(configPath) if !filepath.IsAbs(configPath) { absConfigPath, err := filepath.Abs(configPath) if err != nil { return l, nil, err } configPath = filepath.Clean(absConfigPath) } workingDir := filepath.Dir(configPath) if workingDir != o.WorkingDir { l = l.WithField(placeholders.WorkDirKeyName, workingDir) } newOpts.TerragruntConfigPath = configPath newOpts.WorkingDir = workingDir return l, newOpts, nil } // InsertTerraformCliArgs inserts the given args after the terraform command argument. func (o *Options) InsertTerraformCliArgs(argsToInsert ...string) { if o.TerraformCliArgs == nil { o.TerraformCliArgs = iacargs.New() } parsed := iacargs.New(argsToInsert...) o.TerraformCliArgs.InsertFlag(0, parsed.Flags...) // Handle command field switch { case o.TerraformCliArgs.Command == "": o.TerraformCliArgs.Command = parsed.Command case parsed.Command == "" || parsed.Command == o.TerraformCliArgs.Command: // no-op case iacargs.IsKnownSubCommand(parsed.Command): o.TerraformCliArgs.SubCommand = []string{parsed.Command} default: o.TerraformCliArgs.InsertArguments(0, parsed.Command) } if len(parsed.SubCommand) > 0 { o.TerraformCliArgs.SubCommand = parsed.SubCommand } o.TerraformCliArgs.InsertArguments(0, parsed.Arguments...) } // AppendTerraformCliArgs appends the given args after the current TerraformCliArgs. func (o *Options) AppendTerraformCliArgs(argsToAppend ...string) { if o.TerraformCliArgs == nil { o.TerraformCliArgs = iacargs.New() } parsed := iacargs.New(argsToAppend...) o.TerraformCliArgs.AppendFlag(parsed.Flags...) if parsed.Command != "" { o.TerraformCliArgs.AppendArgument(parsed.Command) } o.TerraformCliArgs.AppendArgument(parsed.Arguments...) if len(parsed.SubCommand) > 0 { o.TerraformCliArgs.SubCommand = parsed.SubCommand } } // TerraformDataDir returns Terraform data directory (.terraform by default, overridden by $TF_DATA_DIR envvar) func (o *Options) TerraformDataDir() string { if tfDataDir, ok := o.Env["TF_DATA_DIR"]; ok { return tfDataDir } return defaultTFDataDir } // DataDir returns the Terraform data directory prepended with the working directory path. func (o *Options) DataDir() string { tfDataDir := o.TerraformDataDir() if filepath.IsAbs(tfDataDir) { return tfDataDir } return filepath.Join(o.WorkingDir, tfDataDir) } // shellRunOptions builds a *shell.ShellOptions from this Options. func (o *Options) shellRunOptions() *shell.ShellOptions { return &shell.ShellOptions{ Writers: o.Writers, WorkingDir: o.WorkingDir, Env: o.Env, TFPath: o.TFPath, EngineConfig: o.EngineConfig, EngineOptions: o.EngineOptions, Experiments: o.Experiments, Telemetry: o.Telemetry, RootWorkingDir: o.RootWorkingDir, Headless: o.Headless, ForwardTFStdout: o.ForwardTFStdout, } } // tfRunOptions builds a *tf.TFOptions from this Options. func (o *Options) tfRunOptions() *tf.TFOptions { return &tf.TFOptions{ JSONLogFormat: o.JSONLogFormat, OriginalTerragruntConfigPath: o.OriginalTerragruntConfigPath, TerragruntConfigPath: o.TerragruntConfigPath, TofuImplementation: o.TofuImplementation, TerraformCliArgs: o.TerraformCliArgs, ShellOptions: o.shellRunOptions(), } } // remoteStateOpts builds a *remotestate.Options from this Options. func (o *Options) remoteStateOpts() *remotestate.Options { return &remotestate.Options{ Options: backend.Options{ Writers: o.Writers, Env: o.Env, IAMRoleOptions: o.IAMRoleOptions, NonInteractive: o.NonInteractive, FailIfBucketCreationRequired: o.FailIfBucketCreationRequired, }, TFRunOpts: o.tfRunOptions(), DisableBucketUpdate: o.DisableBucketUpdate, } } // tflintRunOptions builds a *tflint.TFLintOptions from this Options. func (o *Options) tflintRunOptions() *tflint.TFLintOptions { return &tflint.TFLintOptions{ ShellOptions: o.shellRunOptions(), Writers: o.Writers, WorkingDir: o.WorkingDir, RootWorkingDir: o.RootWorkingDir, TerragruntConfigPath: o.TerragruntConfigPath, MaxFoldersToCheck: o.MaxFoldersToCheck, } } // RunWithErrorHandling runs the given operation and handles errors according to the configuration. func (o *Options) RunWithErrorHandling( ctx context.Context, l log.Logger, r *report.Report, operation func() error, ) error { if o.Errors == nil { return operation() } currentAttempt := 1 reportWorkingDir := o.WorkingDir if o.OriginalTerragruntConfigPath != "" { reportWorkingDir = filepath.Dir(o.OriginalTerragruntConfigPath) } reportDir := filepath.Clean(reportWorkingDir) for { err := operation() if err == nil { return nil } action, recoveryErr := o.Errors.AttemptErrorRecovery(l, err, currentAttempt) if recoveryErr != nil { var maxAttemptsReachedError *errorconfig.MaxAttemptsReachedError if errors.As(recoveryErr, &maxAttemptsReachedError) { return maxAttemptsReachedError } return fmt.Errorf("encountered error while attempting error recovery: %w", recoveryErr) } if action == nil { return err } if action.ShouldIgnore { l.Warnf("Ignoring error, reason: %s", action.IgnoreMessage) if len(action.IgnoreSignals) > 0 { if err := o.handleIgnoreSignals(l, action.IgnoreSignals); err != nil { return err } } run, err := r.EnsureRun(l, reportDir) if err != nil { return err } if err := r.EndRun( l, run.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonErrorIgnored), report.WithCauseIgnoreBlock(action.IgnoreBlockName), ); err != nil { return err } return nil } if action.ShouldRetry { if !o.AutoRetry { return err } l.Warnf( "Encountered retryable error: %s\nAttempt %d of %d. Waiting %d second(s) before retrying...", action.RetryBlockName, currentAttempt, action.RetryAttempts, action.RetrySleepSecs, ) run, err := r.EnsureRun(l, reportDir) if err != nil { return err } if err := r.EndRun( l, run.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonRetrySucceeded), report.WithCauseRetryBlock(action.RetryBlockName), ); err != nil { return err } select { case <-time.After(time.Duration(action.RetrySleepSecs) * time.Second): case <-ctx.Done(): return errors.New(ctx.Err()) } currentAttempt++ continue } return err } } func (o *Options) handleIgnoreSignals(l log.Logger, signals map[string]any) error { signalsFile := filepath.Join(o.WorkingDir, defaultSignalsFile) signalsJSON, err := json.MarshalIndent(signals, "", " ") if err != nil { return err } const ownerPerms = 0644 l.Warnf("Writing error signals to %s", signalsFile) if err := os.WriteFile(signalsFile, signalsJSON, ownerPerms); err != nil { return fmt.Errorf("failed to write signals file %s: %w", signalsFile, err) } return nil } ================================================ FILE: internal/runner/run/prepare_internal_test.go ================================================ package run import ( "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestPrepareInitCommandRunCfg verifies that prepareInitCommandRunCfg inserts // the correct CLI args for various remote_state configurations. // // Bootstrap skip behavior (DisableInit=true preventing bootstrap even when // BackendBootstrap=true) is tested by: // - TestNeedsBootstrapDisableInit in internal/remotestate/remote_state_test.go // - TestAwsDisableInitS3Backend in test/integration_aws_test.go func TestPrepareInitCommandRunCfg(t *testing.T) { t.Parallel() s3Config := backend.Config{ "bucket": "test-bucket", "key": "test.tfstate", "region": "us-east-1", } testCases := []struct { remoteStateCfg *remotestate.Config name string backendBootstrap bool expectBackendArgs bool }{ { name: "nil remote state config - no args inserted", remoteStateCfg: nil, backendBootstrap: false, expectBackendArgs: false, }, { name: "disable_init=false, bootstrap=false - backend-config args inserted", remoteStateCfg: &remotestate.Config{ BackendName: "s3", DisableInit: false, BackendConfig: s3Config, }, backendBootstrap: false, expectBackendArgs: true, }, { name: "disable_init=true, bootstrap=false - backend-config args inserted", remoteStateCfg: &remotestate.Config{ BackendName: "s3", DisableInit: true, BackendConfig: s3Config, }, backendBootstrap: false, expectBackendArgs: true, }, { name: "disable_init=true, bootstrap=true - backend-config args inserted", remoteStateCfg: &remotestate.Config{ BackendName: "s3", DisableInit: true, BackendConfig: s3Config, }, backendBootstrap: true, expectBackendArgs: true, }, { // When generate is set, backend config goes into the generated .tf file, // not via -backend-config= CLI args. name: "disable_init=true, generate=true - no backend-config args", remoteStateCfg: &remotestate.Config{ BackendName: "s3", DisableInit: true, Generate: &remotestate.ConfigGenerate{Path: "backend.tf", IfExists: "overwrite"}, BackendConfig: s3Config, }, backendBootstrap: false, expectBackendArgs: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() opts := &Options{ BackendBootstrap: tc.backendBootstrap, TerraformCliArgs: iacargs.New(), } var cfg runcfg.RunConfig if tc.remoteStateCfg != nil { cfg.RemoteState = *remotestate.New(tc.remoteStateCfg) } err := prepareInitCommandRunCfg(t.Context(), logger.CreateLogger(), opts, &cfg) require.NoError(t, err) allArgs := opts.TerraformCliArgs.Slice() if tc.expectBackendArgs { assert.NotContains(t, allArgs, "-backend=false", "disable_init should not pass -backend=false to terraform") hasBackendConfig := false for _, f := range allArgs { if strings.HasPrefix(f, "-backend-config=") { hasBackendConfig = true break } } assert.True(t, hasBackendConfig, "expected -backend-config= flag in CLI args, got: %v", allArgs) } else { assert.Empty(t, allArgs, "expected no CLI args, got: %v", allArgs) } }) } } ================================================ FILE: internal/runner/run/run.go ================================================ // Package run provides the main entry point for running orchestrated runs. // // These runs are typically OpenTofu/Terraform invocations, but they might be other commands as well. package run import ( "context" "encoding/json" "fmt" "io" "maps" "os" "path/filepath" "regexp" "slices" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/amazonsts" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-multierror" ) const ( CommandNameTerragruntReadConfig = "terragrunt-read-config" NullTFVarsFile = ".terragrunt-null-vars.auto.tfvars.json" ) var TerraformCommandsThatUseState = []string{ "init", "apply", "destroy", "env", "import", "graph", "output", "plan", "push", "refresh", "show", "taint", "untaint", "validate", "force-unlock", "state", } // TerraformCommandsThatDoNotNeedInit is a list of Terraform commands that do not require 'terraform init' to be executed. var TerraformCommandsThatDoNotNeedInit = []string{ "version", // The engine command is a special command that engines can implement to provide additional functionality. // It's not part of the OpenTofu/Terraform CLI API, so we know that we don't consistently need to run 'init'. // Engines can decide to selectively perform inits based on the logic of their engine commands. "engine", } var ModuleRegex = regexp.MustCompile(`module[[:blank:]]+".+"`) // sourceChangeLocks is a map that keeps track of locks for source changes, to ensure we aren't overriding the generated // code while another hook (e.g. `tflint`) is running. We use sync.Map to ensure atomic updates during concurrent access. var sourceChangeLocks = sync.Map{} // Run downloads terraform source if necessary, then runs terraform with the given options and CLI args. // This will forward all the args and extra_arguments directly to Terraform. func Run( ctx context.Context, l log.Logger, opts *Options, r *report.Report, cfg *runcfg.RunConfig, credsGetter *creds.Getter, ) error { engine, err := cfg.EngineOptions() if err != nil { return err } opts.EngineConfig = engine errConfig, err := cfg.ErrorsConfig() if err != nil { return err } // Only overwrite when the config actually defines error rules; // otherwise preserve the built-in default retryable errors. if errConfig != nil { opts.Errors = errConfig } l, terragruntOptionsClone, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return err } terragruntOptionsClone.TerraformCommand = CommandNameTerragruntReadConfig if err = terragruntOptionsClone.RunWithErrorHandling(ctx, l, r, func() error { return ProcessHooks(ctx, l, cfg.Terraform.AfterHooks, terragruntOptionsClone, cfg, nil, r) }); err != nil { return err } // We merge the OriginalIAMRoleOptions into the one from the config, because the CLI passed IAMRoleOptions has // precedence. opts.IAMRoleOptions = iam.MergeRoleOptions( cfg.GetIAMRoleOptions(), opts.OriginalIAMRoleOptions, ) if err = opts.RunWithErrorHandling(ctx, l, r, func() error { return credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env)) }); err != nil { return err } // get the default download dir _, defaultDownloadDir := util.DefaultWorkingAndDownloadDirs(opts.TerragruntConfigPath) // if the download dir hasn't been changed from default, and is set in the config, // then use it if opts.DownloadDir == defaultDownloadDir && cfg.DownloadDir != "" { opts.DownloadDir = cfg.DownloadDir } updatedOpts := opts sourceURL, err := runcfg.GetTerraformSourceURL(opts.Source, opts.SourceMap, opts.OriginalTerragruntConfigPath, cfg) if err != nil { return err } // Always download/copy source to cache directory for consistency. // When no source is specified, sourceURL will be "." (current directory). err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "download_terraform_source", map[string]any{ "sourceUrl": sourceURL, }, func(ctx context.Context) error { updatedOpts, err = DownloadTerraformSource(ctx, l, sourceURL, opts, cfg, r) return err }) if err != nil { return err } // Handle code generation configs, both generate blocks and generate attribute of remote_state. // Note that relative paths are relative to the terragrunt working dir (where terraform is called). if err = GenerateConfig(l, updatedOpts, cfg); err != nil { return err } // We do the debug file generation here, after all the terragrunt generated terraform files are created so that we // can ensure the tfvars json file only includes the vars that are defined in the module. if updatedOpts.Debug { if err := WriteTerragruntDebugFile(l, updatedOpts, cfg); err != nil { return err } } if err := CheckFolderContainsTerraformCode(updatedOpts); err != nil { return err } if err := opts.RunWithErrorHandling(ctx, l, r, func() error { return runTerragruntWithConfig(ctx, l, opts, updatedOpts, cfg, r) }); err != nil { return err } return nil } // GenerateConfig handles code generation using config types (for backwards compatibility). func GenerateConfig(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { rawActualLock, _ := sourceChangeLocks.LoadOrStore(opts.DownloadDir, &sync.Mutex{}) actualLock := rawActualLock.(*sync.Mutex) actualLock.Lock() defer actualLock.Unlock() for _, genCfg := range cfg.GenerateConfigs { if err := codegen.WriteToFile(l, opts.WorkingDir, &genCfg); err != nil { return err } } if cfg.RemoteState.Config != nil && cfg.RemoteState.Generate != nil { if err := cfg.RemoteState.GenerateOpenTofuCode(l, opts.WorkingDir); err != nil { return err } } else if cfg.RemoteState.Config != nil { // We use else if here because we don't need to check the backend configuration is defined when the remote state // block has a `generate` attribute configured. if err := checkTerraformCodeDefinesBackend(opts, cfg.RemoteState.BackendName); err != nil { return err } } return nil } // Runs tofu/terraform with the given options and CLI args. // This will forward all the args and extra_arguments directly to Terraform. // // This function takes in the "original" options which has the unmodified 'WorkingDir' from before downloading the code from the source URL, // and the "updated" options that will contain the updated 'WorkingDir' into which the code has been downloaded func runTerragruntWithConfig( ctx context.Context, l log.Logger, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) error { if cfg.Exclude.ShouldPreventRun(opts.TerraformCommand) { l.Infof("Early exit in terragrunt unit %s due to exclude block with no_run = true", opts.WorkingDir) return nil } if len(cfg.Terraform.ExtraArgs) > 0 { args := FilterTerraformExtraArgs(l, opts, cfg) opts.InsertTerraformCliArgs(args...) maps.Copy(opts.Env, filterTerraformEnvVarsFromExtraArgsRunCfg(opts, cfg)) } if err := SetTerragruntInputsAsEnvVars(l, opts, cfg); err != nil { return err } if opts.TerraformCliArgs.First() == tf.CommandNameInit { if err := prepareInitCommandRunCfg(ctx, l, opts, cfg); err != nil { return err } } else { if err := PrepareNonInitCommand(ctx, l, originalOpts, opts, cfg, r); err != nil { return err } } // Write null-valued inputs to a tfvars.json file that OpenTofu/Terraform will auto-load. nullVarsFile, err := setTerragruntNullValuesRunCfg(opts, cfg) if err != nil { return err } defer func() { if nullVarsFile != "" { if removeErr := os.Remove(nullVarsFile); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { l.Debugf("Failed to remove null values file %s: %v", nullVarsFile, removeErr) } } }() // Now that we've run 'init' and have all the source code locally, we can finally run the patch command if err := checkProtectedModuleRunCfg(opts, cfg); err != nil { return err } return RunActionWithHooks(ctx, l, "terraform", opts, cfg, r, func(childCtx context.Context) error { // Execute the underlying command once; retries and ignores are handled by outer RunWithErrorHandling out, runTerraformError := tf.RunCommandWithOutput(childCtx, l, opts.tfRunOptions(), opts.TerraformCliArgs.Slice()...) var lockFileError error if ShouldCopyLockFile(opts.TerraformCliArgs, &cfg.Terraform) { // Copy the lock file from the Terragrunt working dir (e.g., .terragrunt-cache/xxx/) to the // user's working dir (e.g., /live/stage/vpc). That way, the lock file will end up right next to the user's // terragrunt.hcl and can be checked into version control. Note that in the past, Terragrunt allowed the // user's working dir to be different than the directory where the terragrunt.hcl file lived, so just in // case, we are using the user's working dir here, rather than just looking at the parent dir of the // terragrunt.hcl. However, the default value for the user's working dir, set in options.go, IS just the // parent dir of terragrunt.hcl, so these will likely always be the same. // Use directory from OriginalTerragruntConfigPath to copy locks since WorkingDir point to cache directory lockFileError = runcfg.CopyLockFile(l, opts.RootWorkingDir, opts.Writers.LogShowAbsPaths, opts.WorkingDir, filepath.Dir(opts.OriginalTerragruntConfigPath)) } // If command failed, log a helpful message if runTerraformError != nil { if out == nil { l.Errorf("%s invocation failed in %s", opts.TofuImplementation, opts.WorkingDir) } } return multierror.Append(runTerraformError, lockFileError).ErrorOrNil() }) } // ShouldCopyLockFile verifies if the lock file should be copied to the user's working directory // Terraform 0.14 now manages a lock file for providers. This can be updated // in three ways: // * `terraform init` in a module where no `.terraform.lock.hcl` exists // * `terraform init -upgrade` // * `terraform providers lock` // // In any of these cases, terragrunt should attempt to copy the generated // `.terraform.lock.hcl` // // terraform init is not guaranteed to pull all checksums depending on platforms, // if you already have the provider requested in a cache, or if you are using a mirror. // There are lots of details at [hashicorp/terraform#27264](https://github.com/hashicorp/terraform/issues/27264#issuecomment-743389837) // The `providers lock` sub command enables you to ensure that the lock file is // fully populated. func ShouldCopyLockFile(args *iacargs.IacArgs, terraformConfig *runcfg.TerraformConfig) bool { // If the user has explicitly set NoCopyTerraformLockFile to true, then we should not copy the lock file on any command // This is useful for users who want to manage the lock file themselves outside the working directory if terraformConfig != nil && terraformConfig.NoCopyTerraformLockFile { return false } if args.First() == tf.CommandNameInit { return true } if args.First() == tf.CommandNameProviders && args.Second() == tf.CommandNameLock { return true } return false } // RunActionWithHooks runs the given action function surrounded by hooks. That is, run the before hooks first, then, if there were no // errors, run the action, and finally, run the after hooks. Return any errors hit from the hooks or action. func RunActionWithHooks( ctx context.Context, l log.Logger, description string, opts *Options, cfg *runcfg.RunConfig, r *report.Report, action func(ctx context.Context) error, ) error { var allErrors *errors.MultiError beforeHookErrors := ProcessHooks(ctx, l, cfg.Terraform.BeforeHooks, opts, cfg, allErrors, r) allErrors = allErrors.Append(beforeHookErrors) var actionErrors error if beforeHookErrors == nil { actionErrors = action(ctx) allErrors = allErrors.Append(actionErrors) } else { l.Errorf("Errors encountered running before_hooks. Not running '%s'.", description) } postHookErrors := ProcessHooks(ctx, l, cfg.Terraform.AfterHooks, opts, cfg, allErrors, r) errorHookErrors := processErrorHooks(ctx, l, cfg.Terraform.ErrorHooks, opts, allErrors) allErrors = allErrors.Append(postHookErrors, errorHookErrors) return allErrors.ErrorOrNil() } // SetTerragruntInputsAsEnvVars sets the inputs from Terragrunt configurations to TF_VAR_* environment variables for // OpenTofu/Terraform. func SetTerragruntInputsAsEnvVars(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { asEnvVars, err := ToTerraformEnvVars(l, cfg.Inputs) if err != nil { return err } if opts.Env == nil { opts.Env = map[string]string{} } for key, value := range asEnvVars { // Don't override any env vars the user has already set if _, envVarAlreadySet := opts.Env[key]; !envVarAlreadySet { opts.Env[key] = value } } return nil } // CheckFolderContainsTerraformCode checks if the folder contains Terraform/OpenTofu code func CheckFolderContainsTerraformCode(opts *Options) error { found, err := util.DirContainsTFFiles(opts.WorkingDir) if err != nil { return err } if !found { return errors.New(NoTerraformFilesFound(opts.WorkingDir)) } return nil } // Check that the specified Terraform code defines a backend { ... } block and return an error if doesn't func checkTerraformCodeDefinesBackend(opts *Options, backendType string) error { terraformBackendRegexp, err := regexp.Compile(fmt.Sprintf(`backend[[:blank:]]+"%s"`, backendType)) if err != nil { return errors.New(err) } // Check for backend definitions in .tf and .tofu files using WalkDir definesBackend, err := util.RegexFoundInTFFiles(opts.WorkingDir, terraformBackendRegexp) if err != nil { return err } if definesBackend { return nil } terraformJSONBackendRegexp, err := regexp.Compile(fmt.Sprintf(`(?m)"backend":[[:space:]]*{[[:space:]]*"%s"`, backendType)) if err != nil { return errors.New(err) } definesJSONBackend, err := util.Grep(terraformJSONBackendRegexp, opts.WorkingDir+"/**/*.tf.json") if err != nil { return err } if definesJSONBackend { return nil } return errors.New(BackendNotDefined{ConfigPath: opts.TerragruntConfigPath, WorkingDir: opts.WorkingDir, BackendType: backendType}) } // Returns true if we need to run `terraform init` to download providers func providersNeedInit(opts *Options) bool { pluginsPath := filepath.Join(opts.DataDir(), "plugins") providersPath := filepath.Join(opts.DataDir(), "providers") terraformLockPath := filepath.Join(opts.WorkingDir, tf.TerraformLockFile) return (!util.FileExists(pluginsPath) && !util.FileExists(providersPath)) || !util.FileExists(terraformLockPath) } func prepareInitOptions(l log.Logger, opts *Options) (log.Logger, *Options, error) { // Need to clone the options, so the TerraformCliArgs can be configured to run the init command l, initOptions, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath) if err != nil { return l, nil, err } initOptions.TerraformCliArgs = iacargs.New().SetCommand(tf.CommandNameInit) initOptions.WorkingDir = opts.WorkingDir initOptions.TerraformCommand = tf.CommandNameInit initOptions.Headless = true initOutputForCommands := []string{tf.CommandNamePlan, tf.CommandNameApply} terraformCommand := opts.TerraformCliArgs.First() if !slices.Contains(initOutputForCommands, terraformCommand) { // Since some command can return a json string, it is necessary to suppress output to stdout of the `terraform init` command. initOptions.Writers.Writer = io.Discard } if l.Formatter().DisabledColors() || opts.TerraformCliArgs.Contains(tf.FlagNameNoColor) { initOptions.TerraformCliArgs.AppendFlag(tf.FlagNameNoColor) } return l, initOptions, nil } // Return true if modules aren't already downloaded and the Terraform templates in this project reference modules. // Note that to keep the logic in this code very simple, this code ONLY detects the case where you haven't downloaded // modules at all. Detecting if your downloaded modules are out of date (as opposed to missing entirely) is more // complicated and not something we handle at the moment. func modulesNeedInit(opts *Options) (bool, error) { modulesPath := filepath.Join(opts.DataDir(), "modules") if util.FileExists(modulesPath) { return false, nil } moduleNeedInit := filepath.Join(opts.WorkingDir, ModuleInitRequiredFile) if util.FileExists(moduleNeedInit) { return true, nil } // Check for module definitions in .tf and .tofu files using WalkDir hasModuleDefinition, err := util.RegexFoundInTFFiles(opts.WorkingDir, ModuleRegex) if err != nil { return false, err } return hasModuleDefinition, nil } // remoteStateNeedsInit determines whether remote state initialization is required before running a Terraform command. // It returns true if: // - BackendBootstrap is enabled in options // - Remote state configuration is provided // - The Terraform command uses state (e.g., plan, apply, destroy, output, etc.) // - The remote state backend needs bootstrapping func remoteStateNeedsInit( ctx context.Context, l log.Logger, remoteState *remotestate.RemoteState, opts *Options, ) (bool, error) { // If backend bootstrap is disabled, we don't need to initialize remote state if !opts.BackendBootstrap { return false, nil } // We only configure remote state for the commands that use the tfstate files. We do not configure it for // commands such as "get" or "version". if remoteState == nil || remoteState.Config == nil || !slices.Contains( TerraformCommandsThatUseState, opts.TerraformCliArgs.First(), ) { return false, nil } if ok, err := remoteState.NeedsBootstrap(ctx, l, opts.remoteStateOpts()); err != nil || !ok { return false, err } return true, nil } // FilterTerraformExtraArgs extracts terraform extra arguments using runcfg types. func FilterTerraformExtraArgs(l log.Logger, opts *Options, cfg *runcfg.RunConfig) []string { out := []string{} cmd := opts.TerraformCliArgs.First() for i := range cfg.Terraform.ExtraArgs { arg := &cfg.Terraform.ExtraArgs[i] for _, argCmd := range arg.Commands { if cmd == argCmd { lastArg := opts.TerraformCliArgs.Last() skipVars := (cmd == tf.CommandNameApply || cmd == tf.CommandNameDestroy) && util.IsFile(lastArg) if len(arg.Arguments) > 0 { if skipVars { for _, a := range arg.Arguments { if !strings.HasPrefix(a, "-var") { out = append(out, a) } } } else { out = append(out, arg.Arguments...) } } if !skipVars { for _, file := range arg.VarFiles { out = append(out, "-var-file="+file) } } } } } return out } // ToTerraformEnvVars converts the given variables to a map of environment variables that will expose those variables to Terraform. The // keys will be of the format TF_VAR_xxx and the values will be converted to JSON, which Terraform knows how to read // natively. func ToTerraformEnvVars(l log.Logger, vars map[string]any) (map[string]string, error) { out := map[string]string{} for varName, varValue := range vars { if varValue == nil { continue } envVarName := fmt.Sprintf(tf.EnvNameTFVarFmt, varName) envVarValue, err := util.AsTerraformEnvVarJSONValue(varValue) if err != nil { return nil, err } out[envVarName] = envVarValue } return out, nil } // filterTerraformEnvVarsFromExtraArgsRunCfg extracts terraform env vars from extra args using runcfg types. func filterTerraformEnvVarsFromExtraArgsRunCfg(opts *Options, cfg *runcfg.RunConfig) map[string]string { out := map[string]string{} cmd := opts.TerraformCliArgs.First() for i := range cfg.Terraform.ExtraArgs { arg := &cfg.Terraform.ExtraArgs[i] if len(arg.EnvVars) == 0 { continue } for _, argcmd := range arg.Commands { if cmd == argcmd { maps.Copy(out, arg.EnvVars) } } } return out } // prepareInitCommandRunCfg prepares for terraform init using runcfg types. func prepareInitCommandRunCfg(ctx context.Context, l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { if cfg.RemoteState.Config == nil { return nil } opts.InsertTerraformCliArgs(cfg.RemoteState.GetTFInitArgs()...) // Bootstrap is skipped when either BackendBootstrap is false (the default) or DisableInit is true. // DisableInit is also enforced in RemoteState.NeedsBootstrap (non-init auto-init path); // both must stay in sync to ensure consistent behavior across all command types. if !opts.BackendBootstrap || cfg.RemoteState.DisableInit { return nil } if err := cfg.RemoteState.Bootstrap(ctx, l, opts.remoteStateOpts()); err != nil { return err } return nil } // PrepareNonInitCommand prepares for non-init commands using runcfg types. func PrepareNonInitCommand( ctx context.Context, l log.Logger, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) error { needsInit, err := needsInitRunCfg(ctx, l, opts, cfg) if err != nil { return err } if needsInit { if err := runTerraformInitRunCfg(ctx, l, originalOpts, opts, cfg, r); err != nil { return err } } return nil } // needsInitRunCfg determines if terraform init is needed using runcfg types. func needsInitRunCfg(ctx context.Context, l log.Logger, opts *Options, cfg *runcfg.RunConfig) (bool, error) { if slices.Contains(TerraformCommandsThatDoNotNeedInit, opts.TerraformCliArgs.First()) { return false, nil } if providersNeedInit(opts) { return true, nil } modulesNeedsInit, err := modulesNeedInit(opts) if err != nil { return false, err } if modulesNeedsInit { return true, nil } if cfg.RemoteState.Config == nil { return false, nil } return remoteStateNeedsInit(ctx, l, &cfg.RemoteState, opts) } // runTerraformInitRunCfg runs terraform init using runcfg types. func runTerraformInitRunCfg( ctx context.Context, l log.Logger, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, r *report.Report, ) error { if opts.TerraformCliArgs.First() != tf.CommandNameInit && !opts.AutoInit { l.Warnf("Detected that init is needed, but Auto-Init is disabled. Continuing with further actions, but subsequent terraform commands may fail.") return nil } l, initOptions, err := prepareInitOptions(l, opts) if err != nil { return err } if err := runTerragruntWithConfig(ctx, l, originalOpts, initOptions, cfg, r); err != nil { return err } moduleNeedInit := filepath.Join(opts.WorkingDir, ModuleInitRequiredFile) if util.FileExists(moduleNeedInit) { return os.Remove(moduleNeedInit) } return nil } // checkProtectedModuleRunCfg checks if module is protected using runcfg types. func checkProtectedModuleRunCfg(opts *Options, cfg *runcfg.RunConfig) error { var destroyFlag = false if opts.TerraformCliArgs.First() == tf.CommandNameDestroy { destroyFlag = true } if opts.TerraformCliArgs.Contains("-" + tf.CommandNameDestroy) { destroyFlag = true } if !destroyFlag { return nil } if cfg.PreventDestroy { return errors.New(ModuleIsProtected{ConfigPath: opts.TerragruntConfigPath}) } return nil } // setTerragruntNullValuesRunCfg writes null-valued inputs to a tfvars.json file // that OpenTofu/Terraform will auto-load. This is necessary because OpenTofu/Terraform // cannot accept null values via environment variables (TF_VAR_*), but it can read them // from .auto.tfvars.json files. func setTerragruntNullValuesRunCfg(opts *Options, cfg *runcfg.RunConfig) (string, error) { jsonEmptyVars := make(map[string]any) for varName, varValue := range cfg.Inputs { if varValue == nil { jsonEmptyVars[varName] = nil } } if len(jsonEmptyVars) == 0 { return "", nil } jsonContents, err := json.MarshalIndent(jsonEmptyVars, "", " ") if err != nil { return "", errors.New(err) } varFile := filepath.Join(opts.WorkingDir, NullTFVarsFile) const ownerReadWritePermissions = 0600 if err := os.WriteFile(varFile, jsonContents, os.FileMode(ownerReadWritePermissions)); err != nil { return "", errors.New(err) } return varFile, nil } ================================================ FILE: internal/runner/run/run_test.go ================================================ package run_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSetTerragruntInputsAsEnvVars(t *testing.T) { t.Parallel() testCases := []struct { envVarsInOpts map[string]string inputsInConfig map[string]any expected map[string]string description string }{ { description: "No env vars in opts, no inputs", envVarsInOpts: nil, inputsInConfig: nil, expected: map[string]string{}, }, { description: "A few env vars in opts, no inputs", envVarsInOpts: map[string]string{"foo": "bar"}, inputsInConfig: nil, expected: map[string]string{"foo": "bar"}, }, { description: "No env vars in opts, one input", envVarsInOpts: nil, inputsInConfig: map[string]any{"foo": "bar"}, expected: map[string]string{"TF_VAR_foo": "bar"}, }, { description: "No env vars in opts, a few inputs", envVarsInOpts: nil, inputsInConfig: map[string]any{"foo": "bar", "list": []int{1, 2, 3}, "map": map[string]any{"a": "b"}}, expected: map[string]string{"TF_VAR_foo": "bar", "TF_VAR_list": "[1,2,3]", "TF_VAR_map": `{"a":"b"}`}, }, { description: "A few env vars in opts, a few inputs, no overlap", envVarsInOpts: map[string]string{"foo": "bar", "something": "else"}, inputsInConfig: map[string]any{"foo": "bar", "list": []int{1, 2, 3}, "map": map[string]any{"a": "b"}}, expected: map[string]string{"foo": "bar", "something": "else", "TF_VAR_foo": "bar", "TF_VAR_list": "[1,2,3]", "TF_VAR_map": `{"a":"b"}`}, }, { description: "A few env vars in opts, a few inputs, with overlap", envVarsInOpts: map[string]string{"foo": "bar", "TF_VAR_foo": "original", "TF_VAR_list": "original"}, inputsInConfig: map[string]any{"foo": "bar", "list": []int{1, 2, 3}, "map": map[string]any{"a": "b"}}, expected: map[string]string{"foo": "bar", "TF_VAR_foo": "original", "TF_VAR_list": "original", "TF_VAR_map": `{"a":"b"}`}, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() opts, err := options.NewTerragruntOptionsForTest("mock-path-for-test.hcl") require.NoError(t, err) opts.Env = tc.envVarsInOpts runOpts := configbridge.NewRunOptions(opts) cfg := &runcfg.RunConfig{Inputs: tc.inputsInConfig} l := logger.CreateLogger() require.NoError(t, run.SetTerragruntInputsAsEnvVars(l, runOpts, cfg)) assert.Equal(t, tc.expected, runOpts.Env) }) } } func TestTerragruntTerraformCodeCheck(t *testing.T) { t.Parallel() testCases := []struct { files map[string]string description string valid bool }{ { description: "Directory with plain Terraform", files: map[string]string{ "main.tf": `# Terraform file`, }, valid: true, }, { description: "Directory with plain OpenTofu", files: map[string]string{ "main.tofu": `# OpenTofu file`, }, valid: true, }, { description: "Directory with plain Terraform and OpenTofu", files: map[string]string{ "main.tf": `# Terraform file`, "main.tofu": `# OpenTofu file`, }, valid: true, }, { description: "Directory with JSON formatted Terraform", files: map[string]string{ "main.tf.json": `{"terraform": {"backend": {"s3": {}}}}`, }, valid: true, }, { description: "Directory with JSON formatted OpenTofu", files: map[string]string{ "main.tofu.json": `{"terraform": {"backend": {"s3": {}}}}`, }, valid: true, }, { description: "Directory with JSON formatted Terraform and OpenTofu", files: map[string]string{ "main.tf.json": `{"terraform": {"backend": {"s3": {}}}}`, "main.tofu.json": `{"terraform": {"backend": {"s3": {}}}}`, }, valid: true, }, { description: "Directory with no Terraform or OpenTofu", files: map[string]string{ "main.yaml": `# Not a terraform file`, }, valid: false, }, { description: "Directory with no files", files: map[string]string{}, valid: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } opts, err := options.NewTerragruntOptionsForTest("mock-path-for-test.hcl") require.NoError(t, err) opts.WorkingDir = tmpDir err = run.CheckFolderContainsTerraformCode(configbridge.NewRunOptions(opts)) if (err != nil) && tc.valid { t.Error("valid terraform returned error") } if (err == nil) && !tc.valid { t.Error("invalid terraform did not return error") } }) } } // Legacy retry tests removed; retries now handled via errors blocks func TestToTerraformEnvVars(t *testing.T) { t.Parallel() testCases := []struct { vars map[string]any expected map[string]string description string }{ { description: "empty", vars: map[string]any{}, expected: map[string]string{}, }, { description: "string value", vars: map[string]any{"foo": "bar"}, expected: map[string]string{"TF_VAR_foo": `bar`}, }, { description: "int value", vars: map[string]any{"foo": 42}, expected: map[string]string{"TF_VAR_foo": `42`}, }, { description: "bool value", vars: map[string]any{"foo": true}, expected: map[string]string{"TF_VAR_foo": `true`}, }, { description: "list value", vars: map[string]any{"foo": []string{"a", "b", "c"}}, expected: map[string]string{"TF_VAR_foo": `["a","b","c"]`}, }, { description: "map value", vars: map[string]any{"foo": map[string]any{"a": "b", "c": "d"}}, expected: map[string]string{"TF_VAR_foo": `{"a":"b","c":"d"}`}, }, { description: "nested map value", vars: map[string]any{"foo": map[string]any{"a": []int{1, 2, 3}, "b": "c", "d": map[string]any{"e": "f"}}}, expected: map[string]string{"TF_VAR_foo": `{"a":[1,2,3],"b":"c","d":{"e":"f"}}`}, }, { description: "multiple values", vars: map[string]any{"str": "bar", "int": 42, "bool": false, "list": []int{1, 2, 3}, "map": map[string]any{"a": "b"}}, expected: map[string]string{"TF_VAR_str": `bar`, "TF_VAR_int": `42`, "TF_VAR_bool": `false`, "TF_VAR_list": `[1,2,3]`, "TF_VAR_map": `{"a":"b"}`}, }, { description: "map value with interpolation pattern", vars: map[string]any{"stuff": map[string]any{"foo": "test ${bar} test"}}, expected: map[string]string{"TF_VAR_stuff": `{"foo":"test $${bar} test"}`}, }, { description: "plain string with interpolation pattern not escaped", vars: map[string]any{"mystr": "plain ${bar} string"}, expected: map[string]string{"TF_VAR_mystr": `plain ${bar} string`}, }, { description: "typed slice with interpolation pattern", vars: map[string]any{"list": []string{"${a}", "b"}}, expected: map[string]string{"TF_VAR_list": `["$${a}","b"]`}, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() actual, err := run.ToTerraformEnvVars(l, tc.vars) require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } } func TestFilterTerraformExtraArgs(t *testing.T) { t.Parallel() workingDir := helpers.TmpDirWOSymlinks(t) temporaryFile := createTempFile(t) testCases := []struct { options *options.TerragruntOptions extraArgs runcfg.TerraformExtraArguments expectedArgs []string }{ // Standard scenario { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "bar"}, []string{"apply", "plan", "destroy"}, []string{}, []string{}), []string{"--foo", "bar"}, }, // optional existing var file { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "bar"}, []string{"apply", "plan"}, []string{}, []string{temporaryFile}), []string{"--foo", "bar", "-var-file=" + temporaryFile}, }, // required var file + optional existing var file { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "bar"}, []string{"apply", "plan"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // non existing required var file + non existing optional var file { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "bar"}, []string{"apply", "plan"}, []string{"required.tfvars"}, []string{"optional.tfvars"}), []string{"--foo", "bar", "-var-file=required.tfvars"}, }, // plan providing a folder, var files should stay included { mockCmdOptions(t, workingDir, []string{"plan", workingDir}), mockExtraArgs([]string{"--foo", "bar"}, []string{"plan", "apply"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // apply providing a folder, var files should stay included { mockCmdOptions(t, workingDir, []string{"apply", workingDir}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "-var='key=value'"}, []string{"plan", "apply"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "-var-file=test.tfvars", "-var='key=value'", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // apply providing a file, no var files included { mockCmdOptions(t, workingDir, []string{"apply", temporaryFile}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "apply"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "foo"}, }, // apply providing no params, var files should stay included { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "apply"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // apply with some parameters, providing a file => no var files included { mockCmdOptions(t, workingDir, []string{"apply", "-no-color", "-foo", temporaryFile}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "apply"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "foo"}, }, // destroy providing a folder, var files should stay included { mockCmdOptions(t, workingDir, []string{"destroy", workingDir}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "-var='key=value'"}, []string{"plan", "destroy"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "-var-file=test.tfvars", "-var='key=value'", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // destroy providing a file, no var files included { mockCmdOptions(t, workingDir, []string{"destroy", temporaryFile}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "destroy"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "foo"}, }, // destroy providing no params, var files should stay included { mockCmdOptions(t, workingDir, []string{"destroy"}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "destroy"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo", "-var-file=required.tfvars", "-var-file=" + temporaryFile}, }, // destroy with some parameters, providing a file => no var files included { mockCmdOptions(t, workingDir, []string{"destroy", "-no-color", "-foo", temporaryFile}), mockExtraArgs([]string{"--foo", "-var-file=test.tfvars", "bar", "-var='key=value'", "foo"}, []string{"plan", "destroy"}, []string{"required.tfvars"}, []string{temporaryFile}), []string{"--foo", "bar", "foo"}, }, // Command not included in commands list { mockCmdOptions(t, workingDir, []string{"apply"}), mockExtraArgs([]string{"--foo", "bar"}, []string{"plan", "destroy"}, []string{"required.tfvars"}, []string{"optional.tfvars"}), []string{}, }, } for _, tc := range testCases { config := runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ExtraArgs: []runcfg.TerraformExtraArguments{tc.extraArgs}}, } l := logger.CreateLogger() out := run.FilterTerraformExtraArgs(l, configbridge.NewRunOptions(tc.options), &config) assert.Equal(t, tc.expectedArgs, out) } } var defaultLogLevel = log.DebugLevel func mockCmdOptions(t *testing.T, workingDir string, terraformCliArgs []string) *options.TerragruntOptions { t.Helper() o := mockOptions( t, filepath.Join( workingDir, "terragrunt.hcl", ), workingDir, terraformCliArgs, true, "", false, false, defaultLogLevel, false, ) return o } func mockExtraArgs(arguments, commands, requiredVarFiles, optionalVarFiles []string) runcfg.TerraformExtraArguments { // Compute VarFiles from RequiredVarFiles and OptionalVarFiles, matching what happens // during config translation in pkg/config/translate.go var varFiles []string // Include all specified RequiredVarFiles if len(requiredVarFiles) > 0 { varFiles = append(varFiles, util.RemoveDuplicatesKeepLast(requiredVarFiles)...) } // Include OptionalVarFiles only if they exist if len(optionalVarFiles) > 0 { for _, file := range util.RemoveDuplicatesKeepLast(optionalVarFiles) { if !util.FileExists(file) { continue } varFiles = append(varFiles, file) } } a := runcfg.TerraformExtraArguments{ Name: "test", Arguments: arguments, Commands: commands, RequiredVarFiles: requiredVarFiles, OptionalVarFiles: optionalVarFiles, VarFiles: varFiles, } return a } func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, _ log.Level, debug bool) *options.TerragruntOptions { t.Helper() opts, err := options.NewTerragruntOptionsForTest(terragruntConfigPath) if err != nil { t.Fatalf("error: %v\n", errors.New(err)) } opts.WorkingDir = workingDir opts.TerraformCliArgs = iacargs.New(terraformCliArgs...) opts.NonInteractive = nonInteractive opts.Source = terragruntSource opts.IgnoreDependencyErrors = ignoreDependencyErrors opts.Debug = debug return opts } func createTempFile(t *testing.T) string { t.Helper() tmpFile, err := os.CreateTemp(helpers.TmpDirWOSymlinks(t), "") if err != nil { t.Fatalf("Failed to create temp directory: %s\n", err.Error()) } return tmpFile.Name() } func TestShouldCopyLockFile(t *testing.T) { t.Parallel() type args struct { terraformConfig *runcfg.TerraformConfig args []string } tests := []struct { name string args args want bool }{ { name: "init without terraform config", args: args{ args: []string{"init"}, }, want: true, }, { name: "providers lock without terraform config", args: args{ args: []string{"providers", "lock"}, }, want: true, }, { name: "providers schema without terraform config", args: args{ args: []string{"providers", "schema"}, }, want: false, }, { name: "plan without terraform config", args: args{ args: []string{"plan"}, }, want: false, }, { name: "init with empty terraform config", args: args{ args: []string{"init"}, terraformConfig: &runcfg.TerraformConfig{}, }, want: true, }, { name: "init with CopyTerraformLockFile enabled", args: args{ args: []string{"init"}, terraformConfig: &runcfg.TerraformConfig{ NoCopyTerraformLockFile: false, }, }, want: true, }, { name: "init with CopyTerraformLockFile disabled", args: args{ args: []string{"init"}, terraformConfig: &runcfg.TerraformConfig{ NoCopyTerraformLockFile: true, }, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equalf( t, tt.want, run.ShouldCopyLockFile( iacargs.New(tt.args.args...), tt.args.terraformConfig, ), "shouldCopyLockFile(%v, %v)", tt.args.args, tt.args.terraformConfig) }) } } ================================================ FILE: internal/runner/run/symlink_preserving_git_getter.go ================================================ package run import ( "net/url" "github.com/hashicorp/go-getter" ) // Since go-getter v1.7.9, symbolic links are disabled by default and are automatically // disabled during git submodule operations. This wrapper preserves the original // DisableSymlinks setting to ensure symlinks remain enabled when configured. // symlinkPreservingGitGetter wraps the original git getter to preserve symlink settings type symlinkPreservingGitGetter struct { original getter.Getter client *getter.Client } // Get overrides the original GitGetter to preserve symlink settings func (g *symlinkPreservingGitGetter) Get(dst string, u *url.URL) error { // Store the original DisableSymlinks setting originalDisableSymlinks := g.client.DisableSymlinks // Call the original getter err := g.original.Get(dst, u) // Restore the original DisableSymlinks setting g.client.DisableSymlinks = originalDisableSymlinks return err } // GetFile overrides the original GitGetter to preserve symlink settings func (g *symlinkPreservingGitGetter) GetFile(dst string, u *url.URL) error { return g.original.GetFile(dst, u) } // ClientMode overrides the original GitGetter to preserve symlink settings func (g *symlinkPreservingGitGetter) ClientMode(u *url.URL) (getter.ClientMode, error) { return g.original.ClientMode(u) } // SetClient overrides the original GitGetter to preserve symlink settings func (g *symlinkPreservingGitGetter) SetClient(c *getter.Client) { g.client = c g.original.SetClient(c) } ================================================ FILE: internal/runner/run/tofu_extensions_test.go ================================================ //go:build tofu package run_test import ( "fmt" "os" "path/filepath" "regexp" "testing" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTofuBackendDetectionWithRegex(t *testing.T) { t.Parallel() testCases := []struct { description string files map[string]string backendType string expectBackend bool }{ { description: "Backend in .tofu file", files: map[string]string{ "main.tofu": ` terraform { backend "s3" { bucket = "my-terraform-state" key = "opentofu/terraform.tfstate" region = "us-west-2" } } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" }`, }, backendType: "s3", expectBackend: true, }, { description: "Backend in mixed .tf/.tofu files - backend in .tf", files: map[string]string{ "backend.tf": ` terraform { required_version = ">= 1.0" backend "s3" { bucket = "terraform-state-bucket" key = "mixed/terraform.tfstate" region = "us-east-1" } }`, "resources.tofu": ` resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }`, }, backendType: "s3", expectBackend: true, }, { description: "No backend in .tofu files", files: map[string]string{ "main.tofu": ` resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" }`, }, backendType: "s3", expectBackend: false, }, { description: "Wrong backend type in .tofu files", files: map[string]string{ "main.tofu": ` terraform { backend "s3" { bucket = "my-terraform-state" key = "opentofu/terraform.tfstate" region = "us-west-2" } }`, }, backendType: "gcs", expectBackend: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } terraformBackendRegexp := regexp.MustCompile(fmt.Sprintf(`backend[[:blank:]]+"%s"`, tc.backendType)) hasBackend, err := util.RegexFoundInTFFiles(tmpDir, terraformBackendRegexp) require.NoError(t, err) assert.Equal(t, tc.expectBackend, hasBackend, "For test case: %s", tc.description) }) } } func TestTofuModuleDetectionWithRegex(t *testing.T) { t.Parallel() testCases := []struct { files map[string]string description string expectModules bool }{ { description: "Modules in .tofu file", files: map[string]string{ "main.tofu": ` module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" } module "security_group" { source = "git::https://github.com/example/terraform-modules.git//security-group" vpc_id = module.vpc.vpc_id } output "vpc_id" { value = module.vpc.vpc_id }`, }, expectModules: true, }, { description: "Modules in mixed .tf/.tofu files", files: map[string]string{ "main.tf": ` module "network" { source = "./modules/network" vpc_cidr = "10.0.0.0/16" }`, "compute.tofu": ` module "web_servers" { source = "git::https://github.com/example/terraform-modules.git//web-server" vpc_id = module.network.vpc_id }`, }, expectModules: true, }, { description: "No modules in .tofu files", files: map[string]string{ "main.tofu": ` resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" }`, }, expectModules: false, }, { description: "Backend only (no modules) in .tofu files", files: map[string]string{ "main.tofu": ` terraform { backend "s3" { bucket = "my-terraform-state" key = "opentofu/terraform.tfstate" region = "us-west-2" } } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" }`, }, expectModules: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } moduleRegex := regexp.MustCompile(`module[[:blank:]]+".+"`) hasModules, err := util.RegexFoundInTFFiles(tmpDir, moduleRegex) require.NoError(t, err) assert.Equal(t, tc.expectModules, hasModules, "For test case: %s", tc.description) }) } } func TestTofuCodeCheck(t *testing.T) { t.Parallel() testCases := []struct { files map[string]string description string expectValid bool }{ { description: "Directory with .tofu backend file", files: map[string]string{ "main.tofu": ` terraform { backend "s3" { bucket = "my-terraform-state" key = "opentofu/terraform.tfstate" region = "us-west-2" } } resource "aws_instance" "example" { ami = "ami-0c55b159cbfafe1d0" instance_type = "t2.micro" }`, }, expectValid: true, }, { description: "Directory with .tofu modules file", files: map[string]string{ "main.tofu": ` module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" }`, }, expectValid: true, }, { description: "Directory with mixed .tf/.tofu files", files: map[string]string{ "backend.tf": ` terraform { backend "s3" { bucket = "terraform-state-bucket" key = "mixed/terraform.tfstate" region = "us-east-1" } }`, "resources.tofu": ` resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }`, }, expectValid: true, }, { description: "Directory with existing .tofu file", files: map[string]string{ "main.tofu": `# Simple tofu file`, }, expectValid: true, }, { description: "Directory with existing .tf file", files: map[string]string{ "main.tf": `# Simple tf file`, }, expectValid: true, }, { description: "Directory with both .tf and .tofu files", files: map[string]string{ "main.tf": `# Terraform file`, "main.tofu": `# OpenTofu file`, }, expectValid: true, }, { description: "Directory with no Terraform/OpenTofu files", files: map[string]string{ "main.yaml": `# Not a terraform file`, }, expectValid: false, }, { description: "Empty directory", files: map[string]string{}, expectValid: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } opts, err := options.NewTerragruntOptionsForTest("mock-path-for-test.hcl") require.NoError(t, err) opts.WorkingDir = tmpDir err = run.CheckFolderContainsTerraformCode(configbridge.NewRunOptions(opts)) if tc.expectValid { assert.NoError(t, err, "Expected no error for valid directory: %s", tc.description) } else { assert.Error(t, err, "Expected error for invalid directory: %s", tc.description) } }) } } func TestTofuCacheValidation(t *testing.T) { t.Parallel() testCases := []struct { files map[string]string description string expectHasFiles bool expectError bool }{ { description: "Directory with .tofu files should be detected", files: map[string]string{ "main.tofu": `# Simple tofu file`, }, expectHasFiles: true, expectError: false, }, { description: "Directory with .tofu backend files should be detected", files: map[string]string{ "main.tofu": ` terraform { backend "s3" { bucket = "my-terraform-state" key = "opentofu/terraform.tfstate" region = "us-west-2" } }`, }, expectHasFiles: true, expectError: false, }, { description: "Directory with mixed files should be detected", files: map[string]string{ "backend.tf": ` terraform { backend "s3" { bucket = "terraform-state-bucket" key = "mixed/terraform.tfstate" region = "us-east-1" } }`, "resources.tofu": ` resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }`, }, expectHasFiles: true, expectError: false, }, { description: "Directory with no TF files should not be detected", files: map[string]string{ "main.yaml": `# Not a terraform file`, "script.sh": `#!/bin/bash\necho "hello"`, }, expectHasFiles: false, expectError: false, }, { description: "Empty directory should not be detected", files: map[string]string{}, expectHasFiles: false, expectError: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } hasFiles, err := util.DirContainsTFFiles(tmpDir) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expectHasFiles, hasFiles, "For test case: %s", tc.description) } }) } } ================================================ FILE: internal/runner/run/version_check.go ================================================ package run import ( "context" "encoding/hex" "fmt" "io" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-version" ) // DefaultTerraformVersionConstraint uses the constraint syntax from https://github.com/hashicorp/go-version // This version of Terragrunt was tested to work with Terraform 0.12.0 and above only const DefaultTerraformVersionConstraint = ">= v0.12.0" // TerraformVersionRegex verifies that terraform --version output is in one of the following formats: // - OpenTofu v1.6.0-dev // - Terraform v0.9.5-dev (cad024a5fe131a546936674ef85445215bbc4226+CHANGES) // - Terraform v0.13.0-beta2 // - Terraform v0.12.27 // We only make sure the "v#.#.#" part is present in the output. var TerraformVersionRegex = regexp.MustCompile(`^(\S+)\s(v?\d+\.\d+\.\d+)`) const versionParts = 3 // PopulateTFVersion discovers the currently installed version of OpenTofu/Terraform. // It uses a cache keyed by workingDir and versionFiles to avoid repeated invocations. // Returns the discovered version and implementation type; the caller is responsible // for storing them on *options.TerragruntOptions. func PopulateTFVersion(ctx context.Context, l log.Logger, workingDir string, versionFiles []string, tfOpts *tf.TFOptions) (log.Logger, *version.Version, tfimpl.Type, error) { versionCache := GetRunVersionCache(ctx) cacheKey := computeVersionFilesCacheKey(workingDir, versionFiles) l.Debugf("using cache key for version files: %s", cacheKey) if cachedOutput, found := versionCache.Get(ctx, cacheKey); found { tfImplementation, terraformVersion, err := parseVersionFromCache(cachedOutput) if err != nil { return l, nil, tfimpl.Unknown, err } return l, terraformVersion, tfImplementation, nil } l, terraformVersion, tfImplementation, err := GetTFVersion(ctx, l, tfOpts) if err != nil { return l, nil, tfimpl.Unknown, err } cacheData := formatVersionForCache(tfImplementation, terraformVersion) versionCache.Put(ctx, cacheKey, cacheData) return l, terraformVersion, tfImplementation, nil } // formatVersionForCache formats the implementation and version for the cache func formatVersionForCache(implementation tfimpl.Type, version *version.Version) string { var implStr string switch implementation { case tfimpl.Terraform: implStr = "terraform" case tfimpl.OpenTofu: implStr = "opentofu" case tfimpl.Unknown: implStr = "unknown" } return fmt.Sprintf("%s:%s", implStr, version.String()) } // parseVersionFromCache parses the cache format back to implementation and version for options func parseVersionFromCache(cachedData string) (tfimpl.Type, *version.Version, error) { const expectedParts = 2 parts := strings.SplitN(cachedData, ":", expectedParts) if len(parts) != expectedParts { return tfimpl.Unknown, nil, errors.New(InvalidTerraformVersionSyntax(cachedData)) } implStr := strings.ToLower(parts[0]) versionStr := parts[1] var implementation tfimpl.Type switch implStr { case "terraform": implementation = tfimpl.Terraform case "opentofu": implementation = tfimpl.OpenTofu default: implementation = tfimpl.Unknown } version, err := version.NewVersion(versionStr) if err != nil { return tfimpl.Unknown, nil, err } return implementation, version, nil } // GetTFVersion checks the OpenTofu/Terraform version directly without using cache. // It takes pre-built *tf.TFOptions and runs "terraform version", discarding output // and stripping TF_CLI_ARGS env vars to avoid interference. func GetTFVersion(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions) (log.Logger, *version.Version, tfimpl.Type, error) { // Clone to avoid mutating the caller's options. optsCopy := *tfOpts shellCopy := *optsCopy.ShellOptions optsCopy.ShellOptions = &shellCopy // Discard output — we only need the parsed version string. optsCopy.ShellOptions.Writers.Writer = io.Discard optsCopy.ShellOptions.Writers.ErrWriter = io.Discard // Remove TF_CLI_ARGS* so they don't interfere with "--version". envCopy := make(map[string]string, len(shellCopy.Env)) for key, val := range shellCopy.Env { if !strings.HasPrefix(key, "TF_CLI_ARGS") { envCopy[key] = val } } optsCopy.ShellOptions.Env = envCopy output, err := tf.RunCommandWithOutput(ctx, l, &optsCopy, tf.FlagNameVersion) if err != nil { return l, nil, tfimpl.Unknown, err } terraformVersion, err := ParseTerraformVersion(output.Stdout.String()) if err != nil { return l, nil, tfimpl.Unknown, err } tfImplementation, err := parseTerraformImplementationType(output.Stdout.String()) if err != nil { return l, nil, tfimpl.Unknown, err } if tfImplementation == tfimpl.Unknown { tfImplementation = tfimpl.Terraform l.Warnf("Failed to identify Terraform implementation, fallback to terraform version: %s", terraformVersion) } else { l.Debugf("%s version: %s", tfImplementation, terraformVersion) } return l, terraformVersion, tfImplementation, nil } // CheckTerragruntVersionMeetsConstraint checks that the current version of Terragrunt meets the specified constraint and return an error if it doesn't func CheckTerragruntVersionMeetsConstraint(currentVersion *version.Version, constraint string) error { versionConstraint, err := version.NewConstraint(constraint) if err != nil { return err } checkedVersion := currentVersion if currentVersion.Prerelease() != "" { // The logic in hashicorp/go-version is such that it will not consider a prerelease version to be // compatible with a constraint that does not have a prerelease version. This is not the behavior we want // for Terragrunt, so we strip the prerelease version before checking the constraint. // // https://github.com/hashicorp/go-version/issues/130 checkedVersion = currentVersion.Core() } if !versionConstraint.Check(checkedVersion) { return errors.New(InvalidTerragruntVersion{CurrentVersion: currentVersion, VersionConstraints: versionConstraint}) } return nil } // CheckTerraformVersionMeetsConstraint checks that the current version of Terraform meets the specified constraint and return an error if it doesn't func CheckTerraformVersionMeetsConstraint(currentVersion *version.Version, constraint string) error { versionConstraint, err := version.NewConstraint(constraint) if err != nil { return err } if !versionConstraint.Check(currentVersion) { return errors.New(InvalidTerraformVersion{CurrentVersion: currentVersion, VersionConstraints: versionConstraint}) } return nil } // ParseTerraformVersion parses the output of the terraform --version command func ParseTerraformVersion(versionCommandOutput string) (*version.Version, error) { matches := TerraformVersionRegex.FindStringSubmatch(versionCommandOutput) if len(matches) != versionParts { return nil, errors.New(InvalidTerraformVersionSyntax(versionCommandOutput)) } return version.NewVersion(matches[2]) } // parseTerraformImplementationType - Parse terraform implementation from --version command output func parseTerraformImplementationType(versionCommandOutput string) (tfimpl.Type, error) { matches := TerraformVersionRegex.FindStringSubmatch(versionCommandOutput) if len(matches) != versionParts { return tfimpl.Unknown, errors.New(InvalidTerraformVersionSyntax(versionCommandOutput)) } rawType := strings.ToLower(matches[1]) switch rawType { case "terraform": return tfimpl.Terraform, nil case "opentofu": return tfimpl.OpenTofu, nil default: return tfimpl.Unknown, nil } } // Helper to compute a cache key from the checksums of provided files func computeVersionFilesCacheKey(workingDir string, versionFiles []string) string { var hashes []string for _, file := range versionFiles { path := filepath.Join(workingDir, file) if !util.FileExists(path) { continue } sanitizedPath, err := util.SanitizePath(workingDir, file) if err != nil { sanitizedPath = path } hash, err := util.FileSHA256(sanitizedPath) if err == nil { hashes = append(hashes, file+":"+hex.EncodeToString(hash)) } } cacheKey := "no-version-files" if len(hashes) != 0 { cacheKey = strings.Join(hashes, "|") } return util.EncodeBase64Sha1(cacheKey) } // Custom error types type InvalidTerraformVersionSyntax string func (err InvalidTerraformVersionSyntax) Error() string { return "Unable to parse Terraform version output: " + string(err) } type InvalidTerraformVersion struct { CurrentVersion *version.Version VersionConstraints version.Constraints } type InvalidTerragruntVersion struct { CurrentVersion *version.Version VersionConstraints version.Constraints } func (err InvalidTerraformVersion) Error() string { return fmt.Sprintf("The currently installed version of Terraform (%s) is not compatible with the version Terragrunt requires (%s).", err.CurrentVersion.String(), err.VersionConstraints.String()) } func (err InvalidTerragruntVersion) Error() string { return fmt.Sprintf("The currently installed version of Terragrunt (%s) is not compatible with the version constraint requiring (%s).", err.CurrentVersion.String(), err.VersionConstraints.String()) } ================================================ FILE: internal/runner/run/version_check_internal_test.go ================================================ package run import ( "testing" "github.com/stretchr/testify/assert" ) func Test_computeVersionFilesCacheKey(t *testing.T) { t.Parallel() tests := []struct { name string workingDir string want string versionFiles []string }{ { name: "version files slice is empty", workingDir: "", versionFiles: nil, want: "r01AJjVD7VSXCQk1ORuh_no_NRY", // "no-version-files" }, { name: "workdir contains version files", workingDir: "../../../test/fixtures/version-files-cache-key", versionFiles: []string{ ".terraform-version", ".tool-versions", }, want: "XBE-VO9pOnQjPQDmLQCvSCdckSQ", }, { name: "workdir contains version files and we try to escape the working dir", workingDir: "../../../test/fixtures/version-files-cache-key", versionFiles: []string{ ".terraform-version", ".tool-versions", "../../../dev/random", }, want: "XBE-VO9pOnQjPQDmLQCvSCdckSQ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equalf( t, tt.want, computeVersionFilesCacheKey(tt.workingDir, tt.versionFiles), "computeVersionFilesCacheKey(%v, %v)", tt.workingDir, tt.versionFiles, ) }) } } ================================================ FILE: internal/runner/run/version_check_test.go ================================================ //nolint:unparam package run_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Terraform Version Checking func TestCheckTerraformVersionMeetsConstraintEqual(t *testing.T) { t.Parallel() testCheckTerraformVersionMeetsConstraint(t, "v0.9.3", ">= v0.9.3", true) } func TestCheckTerraformVersionMeetsConstraintGreaterPatch(t *testing.T) { t.Parallel() testCheckTerraformVersionMeetsConstraint(t, "v0.9.4", ">= v0.9.3", true) } func TestCheckTerraformVersionMeetsConstraintGreaterMajor(t *testing.T) { t.Parallel() testCheckTerraformVersionMeetsConstraint(t, "v1.0.0", ">= v0.9.3", true) } func TestCheckTerraformVersionMeetsConstraintLessPatch(t *testing.T) { t.Parallel() testCheckTerraformVersionMeetsConstraint(t, "v0.9.2", ">= v0.9.3", false) } func TestCheckTerraformVersionMeetsConstraintLessMajor(t *testing.T) { t.Parallel() testCheckTerraformVersionMeetsConstraint(t, "v0.8.8", ">= v0.9.3", false) } func TestParseOpenTofuVersionNormal(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "OpenTofu v1.6.0", "v1.6.0", nil) } func TestParseOpenTofuVersionDev(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "OpenTofu v1.6.0-dev", "v1.6.0", nil) } func TestParseTerraformVersionNormal(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.9.3", "v0.9.3", nil) } func TestParseTerraformVersionWithoutV(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform 0.9.3", "0.9.3", nil) } func TestParseTerraformVersionWithDebug(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.9.4 cad024a5fe131a546936674ef85445215bbc4226", "v0.9.4", nil) } func TestParseTerraformVersionWithChanges(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.9.4-dev (cad024a5fe131a546936674ef85445215bbc4226+CHANGES)", "v0.9.4", nil) } func TestParseTerraformVersionWithDev(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.9.4-dev", "v0.9.4", nil) } func TestParseTerraformVersionWithBeta(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.13.0-beta1", "v0.13.0", nil) } func TestParseTerraformVersionWithUnexpectedName(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "Terraform v0.15.0-rc1", "v0.15.0", nil) } func TestParseTerraformVersionInvalidSyntax(t *testing.T) { t.Parallel() testParseTerraformVersion(t, "invalid-syntax", "", run.InvalidTerraformVersionSyntax("invalid-syntax")) } func testCheckTerraformVersionMeetsConstraint(t *testing.T, currentVersion string, versionConstraint string, versionMeetsConstraint bool) { t.Helper() current, err := version.NewVersion(currentVersion) if err != nil { t.Fatalf("Invalid current version specified in test: %v", err) } err = run.CheckTerraformVersionMeetsConstraint(current, versionConstraint) if versionMeetsConstraint && err != nil { assert.NoError(t, err, "Expected Terraform version %s to meet constraint %s, but got error: %v", currentVersion, versionConstraint, err) } else if !versionMeetsConstraint && err == nil { assert.Error(t, err, "Expected Terraform version %s to NOT meet constraint %s, but got back a nil error", currentVersion, versionConstraint) } } func testParseTerraformVersion(t *testing.T, versionString string, expectedVersion string, expectedErr error) { t.Helper() actualVersion, actualErr := run.ParseTerraformVersion(versionString) if expectedErr == nil { expected, err := version.NewVersion(expectedVersion) if err != nil { t.Fatalf("Invalid expected version specified in test: %v", err) } require.NoError(t, actualErr) assert.Equal(t, expected, actualVersion) } else { assert.True(t, errors.IsError(actualErr, expectedErr)) } } // TODO: Refactor these into a test table. // Terragrunt Version Checking func TestCheckTerragruntVersionMeetsConstraintEqual(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v0.23.18", ">= v0.23.18", true) } func TestCheckTerragruntVersionMeetsConstraintGreaterPatch(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v0.23.18", ">= v0.23.9", true) } func TestCheckTerragruntVersionMeetsConstraintGreaterMajor(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v1.0.0", ">= v0.23.18", true) } func TestCheckTerragruntVersionMeetsConstraintLessPatch(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v0.23.17", ">= v0.23.18", false) } func TestCheckTerragruntVersionMeetsConstraintLessMajor(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v0.22.15", ">= v0.23.18", false) } func TestCheckTerragruntVersionMeetsConstraintPrerelease(t *testing.T) { t.Parallel() testCheckTerragruntVersionMeetsConstraint(t, "v0.23.18-alpha202409013", ">= v0.23.18", true) } func testCheckTerragruntVersionMeetsConstraint(t *testing.T, currentVersion string, versionConstraint string, versionMeetsConstraint bool) { t.Helper() current, err := version.NewVersion(currentVersion) if err != nil { t.Fatalf("Invalid current version specified in test: %v", err) } err = run.CheckTerragruntVersionMeetsConstraint(current, versionConstraint) if versionMeetsConstraint && err != nil { t.Fatalf("Expected Terragrunt version %s to meet constraint %s, but got error: %v", currentVersion, versionConstraint, err) } else if !versionMeetsConstraint && err == nil { t.Fatalf("Expected Terragrunt version %s to NOT meet constraint %s, but got back a nil error", currentVersion, versionConstraint) } } ================================================ FILE: internal/runner/runall/errors.go ================================================ package runall import "fmt" type RunAllDisabledErr struct { command string reason string } func (err RunAllDisabledErr) Error() string { return fmt.Sprintf("%s with run --all is disabled: %s", err.command, err.reason) } type MissingCommand struct{} func (err MissingCommand) Error() string { return "Missing run --all command argument (Example: terragrunt run --all plan)" } ================================================ FILE: internal/runner/runall/runall.go ================================================ // Package runall implements the logic for running commands across all units in a stack. package runall import ( "context" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/stacks/clean" "github.com/gruntwork-io/terragrunt/internal/stacks/generate" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/os/stdout" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Known terraform commands that are explicitly not supported in run --all due to the nature of the command. This is // tracked as a map that maps the terraform command to the reasoning behind disallowing the command in run --all. var runAllDisabledCommands = map[string]string{ tf.CommandNameImport: "terraform import should only be run against a single state representation to avoid injecting the wrong object in the wrong state representation.", tf.CommandNameTaint: "terraform taint should only be run against a single state representation to avoid using the wrong state address.", tf.CommandNameUntaint: "terraform untaint should only be run against a single state representation to avoid using the wrong state address.", tf.CommandNameConsole: "terraform console requires stdin, which is shared across all instances of run --all when multiple modules run concurrently.", tf.CommandNameForceUnlock: "lock IDs are unique per state representation and thus should not be run with run --all.", } func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { if opts.TerraformCommand == "" { return errors.New(MissingCommand{}) } reason, isDisabled := runAllDisabledCommands[opts.TerraformCommand] if isDisabled { return RunAllDisabledErr{ command: opts.TerraformCommand, reason: reason, } } runnerOpts := []common.Option{} r := report.NewReport().WithWorkingDir(opts.WorkingDir) if l.Formatter().DisabledColors() || stdout.IsRedirected() { r.WithDisableColor() } if opts.ReportFormat != "" { r.WithFormat(opts.ReportFormat) } if opts.SummaryPerUnit { r.WithShowUnitLevelSummary() } if opts.ReportSchemaFile != "" { defer r.WriteSchemaToFile(opts.ReportSchemaFile) //nolint:errcheck } if opts.ReportFile != "" { defer r.WriteToFile(opts.ReportFile) //nolint:errcheck } // Skip summary for programmatic interactions: // - When JSON output is requested (--json or report format is JSON) // - When running 'output' command (typically for programmatic consumption) if !opts.SummaryDisable && !shouldSkipSummary(opts) { defer func() { if err := r.WriteSummary(opts.Writers.Writer); err != nil { l.Warnf("Failed to write summary: %v", err) } }() } gitFilters := opts.Filters.UniqueGitFilters() // Only create worktrees when git filter expressions are present var ( wts *worktrees.Worktrees err error ) if len(gitFilters) > 0 { wts, err = worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) if err != nil { return errors.Errorf("failed to create worktrees: %w", err) } defer func() { cleanupErr := wts.Cleanup(ctx, l) if cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() } if !opts.NoStackGenerate { // Set the stack config path to the default location in the working directory opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile) // Clean stack folders before calling `generate` when the `--source-update` flag is passed if opts.SourceUpdate { errClean := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_clean", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { l.Debugf("Running stack clean for %s, as part of generate command", opts.WorkingDir) return clean.CleanStacks(l, opts) }) if errClean != nil { return errors.Errorf("failed to clean stack directories under %q: %w", opts.WorkingDir, errClean) } } // Generate the stack configuration with telemetry tracking err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate", map[string]any{ "stack_config_path": opts.TerragruntStackConfigPath, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { return generate.GenerateStacks(ctx, l, opts, wts) }) // Handle any errors during stack generation if err != nil { return errors.Errorf("failed to generate stack file: %w", err) } } else { l.Debugf("Skipping stack generation in %s", opts.WorkingDir) } // Pass worktrees to runner for git filter expressions if wts != nil && len(wts.WorktreePairs) > 0 { runnerOpts = append(runnerOpts, common.WithWorktrees(wts)) } rnr, err := runner.NewStackRunner(ctx, l, opts, runnerOpts...) if err != nil { return err } return RunAllOnStack(ctx, l, opts, rnr, r) } func RunAllOnStack(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, rnr common.StackRunner, r *report.Report) error { l.Debugf("%s", rnr.GetStack().String()) isDestroy := opts.TerraformCliArgs.IsDestroyCommand(opts.TerraformCommand) if err := rnr.LogUnitDeployOrder(l, opts.TerraformCommand, isDestroy, opts.Writers.LogShowAbsPaths); err != nil { return err } var prompt string switch opts.TerraformCommand { case tf.CommandNameApply: prompt = "Are you sure you want to run 'terragrunt apply' in each unit of the run queue displayed above?" case tf.CommandNameDestroy: prompt = "WARNING: Are you sure you want to run `terragrunt destroy` in each unit of the run queue displayed above? There is no undo!" case tf.CommandNameState: prompt = "Are you sure you want to manipulate the state with `terragrunt state` in each unit of the run queue displayed above? Note that absolute paths are shared, while relative paths will be relative to each working directory." } if prompt != "" { shouldRunAll, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter) if err != nil { return err } if !shouldRunAll { // We explicitly exit here to avoid running any defers that might be registered, like from the run summary. os.Exit(0) } } var runErr error telemetryErr := telemetry.TelemeterFromContext(ctx).Collect(ctx, "run_all_on_stack", map[string]any{ "terraform_command": opts.TerraformCommand, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { err := rnr.Run(ctx, l, opts, r) if err != nil { // At this stage, we can't handle the error any further, so we just log it and return nil. // After this point, we'll need to report on what happened, and we want that to happen // after the error summary. l.Errorf("Run failed: %v", err) // Save error to potentially return after telemetry completes runErr = err // Return nil to allow telemetry and reporting to complete return nil } return nil }) // log telemetry error and continue execution if telemetryErr != nil { l.Warnf("Telemetry collection failed: %v", telemetryErr) } return runErr } // shouldSkipSummary determines if summary output should be skipped for programmatic interactions. // Summary is skipped when: // - The command is 'output' (typically used for programmatic consumption) // - JSON output is requested via terraform CLI args (-json flag) // - JSON report format is specified (--report-format=json) func shouldSkipSummary(opts *options.TerragruntOptions) bool { // Skip summary for 'output' command as it's typically used programmatically if opts.TerraformCommand == tf.CommandNameOutput { return true } // Skip summary when JSON output is requested via -json flag if opts.TerraformCliArgs.Normalize(iacargs.SingleDashFlag).Contains(tf.FlagNameJSON) { return true } return false } ================================================ FILE: internal/runner/runall/runall_test.go ================================================ package runall_test import ( "errors" "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMissingRunAllArguments(t *testing.T) { t.Parallel() tgOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) tgOptions.TerraformCommand = "" err = runall.Run(t.Context(), logger.CreateLogger(), tgOptions) require.Error(t, err) var missingCommand runall.MissingCommand ok := errors.As(err, &missingCommand) fmt.Println(err, errors.Unwrap(err)) assert.True(t, ok) } ================================================ FILE: internal/runner/runcfg/types.go ================================================ // Package runcfg provides configuration types for running terragrunt commands. // This package exists to break import cycles between config and run packages. // The `run` package should only import `runcfg`, never `pkg/config`. // // Breaking up the imports this way also allows us to ensure that we never do any config parsing in the `run` package, // which is slow and needs to be handled carefully. package runcfg import ( "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/remotestate" ) // RunConfig contains all configuration data needed to execute terragrunt commands. // This is the primary configuration struct passed to runner packages. type RunConfig struct { // RemoteState contains remote state backend configuration RemoteState remotestate.RemoteState // ProcessedIncludes contains processed include configurations ProcessedIncludes map[string]IncludeConfig // GenerateConfigs contains code generation configurations GenerateConfigs map[string]codegen.GenerateConfig // Inputs contains input variables to pass to terraform Inputs map[string]any // Engine contains engine-specific settings Engine EngineConfig // DownloadDir is the custom download directory for terraform source DownloadDir string // TerragruntVersionConstraint specifies version constraints for terragrunt TerragruntVersionConstraint string // TerraformVersionConstraint specifies version constraints for terraform TerraformVersionConstraint string // TerraformBinary is the path to the terraform/tofu binary TerraformBinary string // IAMRole contains IAM role options for AWS authentication IAMRole iam.RoleOptions // Errors contains error handling configuration Errors ErrorsConfig // Dependencies contains paths to dependent modules Dependencies ModuleDependencies // Terraform contains terraform-specific settings Terraform TerraformConfig // Exclude contains exclusion rules Exclude ExcludeConfig // PreventDestroy prevents terraform destroy from running PreventDestroy bool } // TerraformConfig contains terraform-specific settings. type TerraformConfig struct { // Source is the terraform source URL Source string // IncludeInCopy lists files to include when copying source IncludeInCopy []string // ExcludeFromCopy lists files to exclude when copying source ExcludeFromCopy []string // ExtraArgs contains extra terraform CLI arguments ExtraArgs []TerraformExtraArguments // BeforeHooks are hooks to run before terraform commands BeforeHooks []Hook // AfterHooks are hooks to run after terraform commands AfterHooks []Hook // ErrorHooks are hooks to run on terraform errors ErrorHooks []ErrorHook // NoCopyTerraformLockFile specifies whether to skip copying the lock file // Defaults to false (copy the lock file) when not set NoCopyTerraformLockFile bool } // Hook represents a lifecycle hook (before/after). type Hook struct { // WorkingDir is the directory to run the hook in WorkingDir string // Name is the hook identifier Name string // Commands are terraform commands that trigger this hook Commands []string // Execute is the command to execute Execute []string // RunOnError specifies whether to run on error RunOnError bool // If is a condition for running the hook If bool // SuppressStdout suppresses stdout output SuppressStdout bool } // ErrorHook represents an error handling hook. type ErrorHook struct { // WorkingDir is the directory to run the hook in WorkingDir string // Name is the hook identifier Name string // Commands are terraform commands that trigger this hook Commands []string // Execute is the command to execute Execute []string // OnErrors are error patterns that trigger this hook OnErrors []string // SuppressStdout suppresses stdout output SuppressStdout bool } // TerraformExtraArguments represents extra CLI arguments for terraform. type TerraformExtraArguments struct { // Arguments are the extra CLI arguments Arguments []string // RequiredVarFiles are required variable files RequiredVarFiles []string // OptionalVarFiles are optional variable files OptionalVarFiles []string // EnvVars are environment variables to set EnvVars map[string]string // Name is the identifier for this set of arguments Name string // VarFiles contains the computed list of variable files (required + existing optional files) // This is computed during config translation. VarFiles []string // Commands are terraform commands these arguments apply to Commands []string } // ExcludeConfig contains exclusion rules. type ExcludeConfig struct { // Actions are the actions to exclude Actions []string // If is the condition for exclusion If bool // NoRun specifies whether to skip running NoRun bool // ExcludeDependencies specifies whether to exclude dependencies ExcludeDependencies bool } // IsActionListed checks if the action is listed in the exclude block. func (e *ExcludeConfig) IsActionListed(action string) bool { return IsActionListedInExclude(e.Actions, action) } // ShouldPreventRun returns true if execution should be prevented. func (e *ExcludeConfig) ShouldPreventRun(command string) bool { return ShouldPreventRunBasedOnExclude(e.Actions, &e.NoRun, e.If, command) } // IncludeConfig represents an included configuration. type IncludeConfig struct { // MergeStrategy specifies how to merge the include MergeStrategy string // Name is the include name/label Name string // Path is the path to the included config Path string // Expose specifies whether to expose the include Expose bool } // ModuleDependencies represents paths to dependent modules. type ModuleDependencies struct { // Paths are the paths to dependent modules Paths []string } // EngineConfig represents engine-specific configuration. type EngineConfig struct { // Version is the engine version Version string // Type is the engine type Type string // Meta contains engine metadata Meta *cty.Value // Source is the engine source URL Source string // Enable indicates whether the engine block was specified, // meaning that we should be using the engine. Enable bool } // ErrorsConfig represents the top-level errors configuration. type ErrorsConfig struct { // Retry contains retry block configurations Retry []*RetryBlock // Ignore contains ignore block configurations Ignore []*IgnoreBlock } // RetryBlock represents a labeled retry block. type RetryBlock struct { // Label is the name of the retry block Label string // RetryableErrors are error patterns that trigger retry RetryableErrors []string // MaxAttempts is the maximum number of retry attempts MaxAttempts int // SleepIntervalSec is the sleep interval between retries in seconds SleepIntervalSec int } // IgnoreBlock represents a labeled ignore block. type IgnoreBlock struct { // Signals contains signal mappings Signals map[string]cty.Value // Label is the name of the ignore block Label string // Message is an optional message for ignored errors Message string // IgnorableErrors are error patterns that should be ignored IgnorableErrors []string } ================================================ FILE: internal/runner/runcfg/util.go ================================================ package runcfg import ( "fmt" "net/url" "path/filepath" "regexp" "slices" "strings" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-getter" ) // DefaultEngineType is the default engine type. const DefaultEngineType = "rpc" // CopyLockFile copies the lock file from the source folder to the destination folder. // // Terraform 0.14 now generates a lock file when you run `terraform init`. // If any such file exists, this function will copy the lock file to the destination folder. func CopyLockFile(l log.Logger, rootWorkingDir string, logShowAbsPaths bool, sourceFolder, destinationFolder string) error { sourceLockFilePath := filepath.Join(sourceFolder, tf.TerraformLockFile) destinationLockFilePath := filepath.Join(destinationFolder, tf.TerraformLockFile) if util.FileExists(sourceLockFilePath) { l.Debugf( "Copying lock file from %s to %s", util.RelPathForLog( rootWorkingDir, sourceLockFilePath, logShowAbsPaths, ), util.RelPathForLog( rootWorkingDir, destinationLockFilePath, logShowAbsPaths, ), ) return util.CopyFile(sourceLockFilePath, destinationLockFilePath) } return nil } // GetTerraformSourceURL returns the source URL for OpenTofu/Terraform configuration. // // There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific // URL: via a command-line option or via an entry in the Terragrunt configuration. If the user used one of these, this // method returns the source URL. If neither is specified, returns "." to indicate the current directory should be // used as the source, ensuring a .terragrunt-cache directory is always created for consistency. func GetTerraformSourceURL(source string, sourceMap map[string]string, originalConfigPath string, cfg *RunConfig) (string, error) { switch { case source != "": return source, nil case cfg != nil && cfg.Terraform.Source != "": return AdjustSourceWithMap(sourceMap, cfg.Terraform.Source, originalConfigPath) default: return ".", nil } } // AdjustSourceWithMap implements the --terragrunt-source-map feature. This function will check if the URL portion of a // terraform source matches any entry in the provided source map and if it does, replace it with the configured source // in the map. Note that this only performs literal matches with the URL portion. // // Example: // Suppose terragrunt is called with: // // --terragrunt-source-map git::ssh://git@github.com/gruntwork-io/i-dont-exist.git=/path/to/local-modules // // and the terraform source is: // // git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixtures/source-map/modules/app?ref=master // // This function will take that source and transform it to: // // /path/to/local-modules//fixtures/source-map/modules/app func AdjustSourceWithMap(sourceMap map[string]string, source string, modulePath string) (string, error) { // Skip logic if source map is not configured if len(sourceMap) == 0 { return source, nil } // use go-getter to split the module source string into a valid URL and subdirectory (if // is present) moduleURL, moduleSubdir := getter.SourceDirSubdir(source) // if both URL and subdir are missing, something went terribly wrong if moduleURL == "" && moduleSubdir == "" { return "", errors.New(InvalidSourceURLWithMapError{ModulePath: modulePath, ModuleSourceURL: source}) } // If module URL is missing, return the source as is as it will not match anything in the map. if moduleURL == "" { return source, nil } // Before looking up in sourceMap, make sure to drop any query parameters. moduleURLParsed, err := url.Parse(moduleURL) if err != nil { return source, err } moduleURLParsed.RawQuery = "" moduleURLQuery := moduleURLParsed.String() // Check if there is an entry to replace the URL portion in the map. Return the source as is if there is no entry in // the map. sourcePath, hasKey := sourceMap[moduleURLQuery] if !hasKey { return source, nil } // Since there is a source mapping, replace the module URL portion with the entry in the map, and join with the // subdir. // If subdir is missing, check if we can obtain a valid module name from the URL portion. if moduleSubdir == "" { moduleSubdirFromURL, err := GetModulePathFromSourceURL(moduleURL) if err != nil { return moduleSubdirFromURL, err } moduleSubdir = moduleSubdirFromURL } return util.JoinTerraformModulePath(sourcePath, moduleSubdir), nil } // InvalidSourceURLWithMapError is an error type for invalid source URLs when using source map. type InvalidSourceURLWithMapError struct { ModulePath string ModuleSourceURL string } func (err InvalidSourceURLWithMapError) Error() string { return fmt.Sprintf("The --source-map parameter was passed in, but the source URL in the module at '%s' is invalid: '%s'. Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!", err.ModulePath, err.ModuleSourceURL) } // ParsingModulePathError is an error type for when module path cannot be parsed from source URL. type ParsingModulePathError struct { ModuleSourceURL string } func (err ParsingModulePathError) Error() string { return fmt.Sprintf("Unable to obtain the module path from the source URL '%s'. Ensure that the URL is in a supported format.", err.ModuleSourceURL) } // Regexp for module name extraction. It assumes that the query string has already been stripped off. // Then we simply capture anything after the last slash, and before `.` or end of string. var moduleNameRegexp = regexp.MustCompile(`(?:.+/)(.+?)(?:\.|$)`) // GetModulePathFromSourceURL parses sourceUrl not containing '//', and attempt to obtain a module path. // Example: // // sourceUrl = "git::ssh://git@ghe.ourcorp.com/OurOrg/module-name.git" // will return "module-name". func GetModulePathFromSourceURL(sourceURL string) (string, error) { // strip off the query string if present sourceURL = strings.Split(sourceURL, "?")[0] matches := moduleNameRegexp.FindStringSubmatch(sourceURL) // if regexp returns less/more than the full match + 1 capture group, then something went wrong with regex (invalid source string) const matchedPats = 2 if len(matches) != matchedPats { return "", errors.New(ParsingModulePathError{ModuleSourceURL: sourceURL}) } return matches[1], nil } // EngineOptions fetches engine options from the RunConfig. func (cfg *RunConfig) EngineOptions() (*engine.EngineConfig, error) { if !cfg.Engine.Enable { return nil, nil } // in case of Meta is null, set empty meta meta := map[string]any{} if cfg.Engine.Meta != nil { parsedMeta, err := ctyhelper.ParseCtyValueToMap(*cfg.Engine.Meta) if err != nil { return nil, err } meta = parsedMeta } version := cfg.Engine.Version engineType := cfg.Engine.Type // if type is null or empty, set to "rpc" if len(engineType) == 0 { engineType = DefaultEngineType } return &engine.EngineConfig{ Source: cfg.Engine.Source, Version: version, Type: engineType, Meta: meta, }, nil } // GetIAMRoleOptions returns the IAM role options from the RunConfig. func (cfg *RunConfig) GetIAMRoleOptions() iam.RoleOptions { return cfg.IAMRole } // ErrorsConfig fetches errors configuration from the RunConfig. // Returns nil when no retry or ignore blocks are defined, so callers // can preserve default error handling (e.g. built-in retryable errors). func (cfg *RunConfig) ErrorsConfig() (*errorconfig.Config, error) { if len(cfg.Errors.Retry) == 0 && len(cfg.Errors.Ignore) == 0 { return nil, nil } result := &errorconfig.Config{ Retry: make(map[string]*errorconfig.RetryConfig), Ignore: make(map[string]*errorconfig.IgnoreConfig), } for _, retryBlock := range cfg.Errors.Retry { if retryBlock == nil { continue } // Validate retry settings if retryBlock.MaxAttempts < 1 { return nil, errors.Errorf("cannot have less than 1 max retry in errors.retry %q, but you specified %d", retryBlock.Label, retryBlock.MaxAttempts) } if retryBlock.SleepIntervalSec < 0 { return nil, errors.Errorf("cannot sleep for less than 0 seconds in errors.retry %q, but you specified %d", retryBlock.Label, retryBlock.SleepIntervalSec) } compiledPatterns := make([]*errorconfig.Pattern, 0, len(retryBlock.RetryableErrors)) for _, pattern := range retryBlock.RetryableErrors { value, err := errorsPattern(pattern) if err != nil { return nil, errors.Errorf("invalid retry pattern %q in block %q: %w", pattern, retryBlock.Label, err) } compiledPatterns = append(compiledPatterns, value) } result.Retry[retryBlock.Label] = &errorconfig.RetryConfig{ Name: retryBlock.Label, RetryableErrors: compiledPatterns, MaxAttempts: retryBlock.MaxAttempts, SleepIntervalSec: retryBlock.SleepIntervalSec, } } for _, ignoreBlock := range cfg.Errors.Ignore { if ignoreBlock == nil { continue } var signals map[string]any if ignoreBlock.Signals != nil { value := convertValuesMapToCtyVal(ignoreBlock.Signals) var err error signals, err = ctyhelper.ParseCtyValueToMap(value) if err != nil { return nil, err } } compiledPatterns := make([]*errorconfig.Pattern, 0, len(ignoreBlock.IgnorableErrors)) for _, pattern := range ignoreBlock.IgnorableErrors { value, err := errorsPattern(pattern) if err != nil { return nil, errors.Errorf("invalid ignore pattern %q in block %q: %w", pattern, ignoreBlock.Label, err) } compiledPatterns = append(compiledPatterns, value) } result.Ignore[ignoreBlock.Label] = &errorconfig.IgnoreConfig{ Name: ignoreBlock.Label, IgnorableErrors: compiledPatterns, Message: ignoreBlock.Message, Signals: signals, } } return result, nil } // errorsPattern builds an ErrorsPattern from a string pattern. func errorsPattern(pattern string) (*errorconfig.Pattern, error) { isNegative := false p := pattern if len(p) > 0 && p[0] == '!' { isNegative = true p = p[1:] } compiled, err := regexp.Compile(p) if err != nil { return nil, err } return &errorconfig.Pattern{ Pattern: compiled, Negative: isNegative, }, nil } // convertValuesMapToCtyVal takes a map of name - cty.Value pairs and converts to a single cty.Value object. func convertValuesMapToCtyVal(valMap map[string]cty.Value) cty.Value { if len(valMap) == 0 { // Return an empty object instead of NilVal for empty maps. return cty.EmptyObjectVal } // Use cty.ObjectVal directly instead of gocty.ToCtyValue to preserve marks (like sensitive()) return cty.ObjectVal(valMap) } // Exclude action constants const ( AllActions = "all" AllExcludeOutputActions = "all_except_output" TgOutput = "output" ) // IsActionListedInExclude checks if the action is listed in the exclude block actions. // This is a shared utility function that provides a single source of truth for exclude action matching logic. // It handles special action values: // - "all": matches any action // - "all_except_output": matches any action except "output" // - Case-insensitive matching for regular actions func IsActionListedInExclude(actions []string, action string) bool { if len(actions) == 0 { return false } actionLower := strings.ToLower(action) for _, checkAction := range actions { if checkAction == AllActions { return true } if checkAction == AllExcludeOutputActions && actionLower != TgOutput { return true } if strings.ToLower(checkAction) == actionLower { return true } } return false } // ShouldPreventRunBasedOnExclude determines if execution should be prevented based on exclude configuration. // This is a shared utility function that provides a single source of truth for exclude run prevention logic. // Parameters: // - actions: list of actions in the exclude block // - noRun: pointer to no_run flag (nil means not set) // - ifCondition: the if condition value // - command: the command/action to check func ShouldPreventRunBasedOnExclude(actions []string, noRun *bool, ifCondition bool, command string) bool { if !ifCondition { return false } switch { case noRun == nil: // When no_run isn't set, preserve legacy behavior: only exact action matches prevent a run. return slices.Contains(actions, command) case !*noRun: // When no_run is explicitly false, never prevent the run. return false default: // When no_run is explicitly true, use the shared action matcher (supports special values). return IsActionListedInExclude(actions, command) } } ================================================ FILE: internal/runner/runcfg/util_test.go ================================================ package runcfg_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAdjustSourceWithMap(t *testing.T) { t.Parallel() testCases := []struct { name string sourceMap map[string]string source string modulePath string expectedResult string expectedError string }{ { name: "empty source map returns source unchanged", sourceMap: nil, source: "git::ssh://git@github.com/org/repo.git//path/to/module", modulePath: "/path/to/config.hcl", expectedResult: "git::ssh://git@github.com/org/repo.git//path/to/module", expectedError: "", }, { name: "basic source map match with subdirectory", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "git::ssh://git@github.com/org/repo.git//path/to/module", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//path/to/module", expectedError: "", }, { name: "source map match with query parameters", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "git::ssh://git@github.com/org/repo.git//path/to/module?ref=master", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//path/to/module", expectedError: "", }, { name: "source map match without subdirectory - extracts module name", sourceMap: map[string]string{ "git::ssh://git@github.com/org/module-name.git": "/local/path", }, source: "git::ssh://git@github.com/org/module-name.git?ref=v1.0.0", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//module-name", expectedError: "", }, { name: "no match in source map returns source unchanged", sourceMap: map[string]string{ "git::ssh://git@github.com/org/other-repo.git": "/local/path", }, source: "git::ssh://git@github.com/org/repo.git//path/to/module", modulePath: "/path/to/config.hcl", expectedResult: "git::ssh://git@github.com/org/repo.git//path/to/module", expectedError: "", }, { name: "empty URL and subdir returns error", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "", modulePath: "/path/to/config.hcl", expectedResult: "", expectedError: "invalid", }, { name: "empty URL but has subdir returns source unchanged", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "//path/to/module", modulePath: "/path/to/config.hcl", expectedResult: "//path/to/module", expectedError: "", }, { name: "multiple source map entries - matches correct one", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo1.git": "/local/path1", "git::ssh://git@github.com/org/repo2.git": "/local/path2", }, source: "git::ssh://git@github.com/org/repo2.git//path/to/module", modulePath: "/path/to/config.hcl", expectedResult: "/local/path2//path/to/module", expectedError: "", }, { name: "source map with trailing slash in mapped path", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path/", }, source: "git::ssh://git@github.com/org/repo.git//module", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//module", expectedError: "", }, { name: "source map with leading slash in subdirectory", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "git::ssh://git@github.com/org/repo.git///module", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//module", expectedError: "", }, { name: "complex URL with multiple query parameters", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, source: "git::ssh://git@github.com/org/repo.git//path/to/module?ref=master&depth=1", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//path/to/module", expectedError: "", }, { name: "module name extraction from URL with .git extension", sourceMap: map[string]string{ "git::ssh://git@github.com/org/my-terraform-module.git": "/local/path", }, source: "git::ssh://git@github.com/org/my-terraform-module.git", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//my-terraform-module", expectedError: "", }, { name: "module name extraction from URL without .git extension", sourceMap: map[string]string{ "git::ssh://git@github.com/org/my-module": "/local/path", }, source: "git::ssh://git@github.com/org/my-module", modulePath: "/path/to/config.hcl", expectedResult: "/local/path//my-module", expectedError: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result, err := runcfg.AdjustSourceWithMap(tc.sourceMap, tc.source, tc.modulePath) if tc.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.expectedError) } else { require.NoError(t, err) assert.Equal(t, tc.expectedResult, result) } }) } } func TestGetModulePathFromSourceURL(t *testing.T) { t.Parallel() testCases := []struct { name string sourceURL string expectedResult string expectedError string }{ { name: "extract module name from git URL with .git", sourceURL: "git::ssh://git@github.com/org/module-name.git", expectedResult: "module-name", expectedError: "", }, { name: "extract module name from git URL without .git", sourceURL: "git::ssh://git@github.com/org/module-name", expectedResult: "module-name", expectedError: "", }, { name: "extract module name with query parameters", sourceURL: "git::ssh://git@github.com/org/my-module.git?ref=master", expectedResult: "my-module", expectedError: "", }, { name: "extract module name with dashes", sourceURL: "git::ssh://git@github.com/org/my-terraform-module.git", expectedResult: "my-terraform-module", expectedError: "", }, { name: "extract module name with underscores", sourceURL: "git::ssh://git@github.com/org/my_terraform_module.git", expectedResult: "my_terraform_module", expectedError: "", }, { name: "invalid URL format returns error", sourceURL: "invalid-url", expectedResult: "", expectedError: "Unable to obtain the module path", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result, err := runcfg.GetModulePathFromSourceURL(tc.sourceURL) if tc.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.expectedError) } else { require.NoError(t, err) assert.Equal(t, tc.expectedResult, result) } }) } } func TestGetTerraformSourceURL(t *testing.T) { t.Parallel() testCases := []struct { name string source string sourceMap map[string]string originalConfigPath string cfg *runcfg.RunConfig expectedResult string expectedError string }{ { name: "source from options takes precedence", source: "git::ssh://git@github.com/org/repo.git", sourceMap: map[string]string{}, cfg: &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ Source: "git::ssh://git@github.com/org/other-repo.git", }, }, expectedResult: "git::ssh://git@github.com/org/repo.git", expectedError: "", }, { name: "source from config with source map", source: "", sourceMap: map[string]string{ "git::ssh://git@github.com/org/repo.git": "/local/path", }, originalConfigPath: "/path/to/config.hcl", cfg: &runcfg.RunConfig{ Terraform: runcfg.TerraformConfig{ Source: "git::ssh://git@github.com/org/repo.git//module?ref=master", }, }, expectedResult: "/local/path//module", expectedError: "", }, { name: "no source returns current directory", source: "", sourceMap: map[string]string{}, cfg: &runcfg.RunConfig{}, expectedResult: ".", expectedError: "", }, { name: "nil terraform config returns current directory", source: "", sourceMap: map[string]string{}, cfg: &runcfg.RunConfig{}, expectedResult: ".", expectedError: "", }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() result, err := runcfg.GetTerraformSourceURL(tc.source, tc.sourceMap, tc.originalConfigPath, tc.cfg) if tc.expectedError != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.expectedError) } else { require.NoError(t, err) assert.Equal(t, tc.expectedResult, result) } }) } } func TestInvalidSourceURLWithMapError(t *testing.T) { t.Parallel() err := runcfg.InvalidSourceURLWithMapError{ ModulePath: "/path/to/config.hcl", ModuleSourceURL: "invalid-source", } errorMsg := err.Error() assert.Contains(t, errorMsg, "/path/to/config.hcl") assert.Contains(t, errorMsg, "invalid-source") assert.Contains(t, errorMsg, "invalid") } func TestParsingModulePathError(t *testing.T) { t.Parallel() err := runcfg.ParsingModulePathError{ ModuleSourceURL: "git::invalid-url", } errorMsg := err.Error() assert.Contains(t, errorMsg, "git::invalid-url") assert.Contains(t, errorMsg, "Unable to obtain the module path") } ================================================ FILE: internal/runner/runner.go ================================================ // Package runner provides logic for applying Stacks and Units Terragrunt. package runner import ( "context" "maps" "path/filepath" "slices" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // NewStackRunner discovers all Terragrunt units under the working directory and // assembles them into a StackRunner that can apply or destroy them. func NewStackRunner( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, runnerOpts ...common.Option, ) (common.StackRunner, error) { return runnerpool.Build(ctx, l, opts, runnerOpts...) } // BuildUnitOpts is a facade for runnerpool.BuildUnitOpts. func BuildUnitOpts(l log.Logger, stackOpts *options.TerragruntOptions, unit *component.Unit) (*options.TerragruntOptions, log.Logger, error) { return runnerpool.BuildUnitOpts(l, stackOpts, unit) } // FindDependentUnits - find dependent units for a given unit. // 1. Find root git top level directory and build list of units // 2. Iterate over includes from opts if git top level directory detection failed // 3. Filter found units for those that have dependencies on the unit in the working directory func FindDependentUnits( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) []*component.Unit { matchedUnitsMap := make(map[string]*component.Unit) pathsToCheck := discoverPathsToCheck(ctx, l, opts, cfg) for _, dir := range pathsToCheck { maps.Copy( matchedUnitsMap, findMatchingUnitsInPath( ctx, l, dir, opts, ), ) } matchedUnits := make([]*component.Unit, 0, len(matchedUnitsMap)) for _, unit := range matchedUnitsMap { matchedUnits = append(matchedUnits, unit) } return matchedUnits } // discoverPathsToCheck finds root git top level directory and builds list of units, or iterates over includes if git detection fails. func discoverPathsToCheck(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) []string { var pathsToCheck []string if gitTopLevelDir, err := shell.GitTopLevelDir(ctx, l, opts.Env, opts.WorkingDir); err == nil { pathsToCheck = append(pathsToCheck, gitTopLevelDir) } else { uniquePaths := make(map[string]bool) for _, includePath := range terragruntConfig.ProcessedIncludes { uniquePaths[filepath.Dir(includePath.Path)] = true } for path := range uniquePaths { pathsToCheck = append(pathsToCheck, path) } } return pathsToCheck } // findMatchingUnitsInPath builds the stack from the config directory and filters units by working dir dependencies. func findMatchingUnitsInPath(ctx context.Context, l log.Logger, dir string, opts *options.TerragruntOptions) map[string]*component.Unit { matchedUnitsMap := make(map[string]*component.Unit) // Construct the full path to terragrunt.hcl in the directory configPath := filepath.Join(dir, filepath.Base(opts.TerragruntConfigPath)) cfgOpts, err := options.NewTerragruntOptionsWithConfigPath(configPath) if err != nil { l.Debugf("Failed to build terragrunt options from %s %v", configPath, err) return matchedUnitsMap } cfgOpts.Env = opts.Env cfgOpts.OriginalTerragruntConfigPath = opts.OriginalTerragruntConfigPath cfgOpts.TerraformCommand = opts.TerraformCommand cfgOpts.TerraformCliArgs = opts.TerraformCliArgs cfgOpts.CheckDependentUnits = opts.CheckDependentUnits cfgOpts.NonInteractive = true l.Infof("Discovering dependent units for %s", opts.TerragruntConfigPath) rnr, err := NewStackRunner(ctx, l, cfgOpts) if err != nil { l.Debugf("Failed to build unit stack %v", err) return matchedUnitsMap } stack := rnr.GetStack() dependentUnits := rnr.ListStackDependentUnits() deps, found := dependentUnits[opts.WorkingDir] if found { for _, unit := range stack.Units { if slices.Contains(deps, unit.Path()) { matchedUnitsMap[unit.Path()] = unit } } } return matchedUnitsMap } ================================================ FILE: internal/runner/runnerpool/builder.go ================================================ package runnerpool import ( "context" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Build stack runner using discovery and queueing mechanisms. func Build( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, runnerOpts ...common.Option, ) (common.StackRunner, error) { discovered, err := discoverWithRetry(ctx, l, opts, runnerOpts...) if err != nil { return nil, err } rnr, err := createRunner(ctx, l, opts, discovered, runnerOpts...) if err != nil { return nil, err } if err := checkVersionConstraints(ctx, l, opts, rnr.GetStack().Units); err != nil { return nil, err } return rnr, nil } ================================================ FILE: internal/runner/runnerpool/builder_helpers.go ================================================ package runnerpool import ( "context" "path/filepath" "runtime" "slices" "golang.org/x/sync/errgroup" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // telemetry event names used in this file const ( telemetryDiscovery = "runner_pool_discovery" telemetryCreation = "runner_pool_creation" ) // doWithTelemetry is a small helper to standardize telemetry collection calls. func doWithTelemetry(ctx context.Context, name string, fields map[string]any, fn func(context.Context) error) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, name, fields, fn) } // resolveWorkingDir determines the canonical working directory for discovery. func resolveWorkingDir(opts *options.TerragruntOptions) string { if opts.RootWorkingDir != "" { return opts.RootWorkingDir } return opts.WorkingDir } // buildConfigFilenames returns the list of config filenames to consider, including custom if provided. func buildConfigFilenames(opts *options.TerragruntOptions) []string { configFilenames := append([]string{}, discovery.DefaultConfigFilenames...) customConfigName := filepath.Base(opts.TerragruntConfigPath) isCustom := !slices.Contains(discovery.DefaultConfigFilenames, customConfigName) if isCustom && customConfigName != "" && customConfigName != "." { configFilenames = append(configFilenames, customConfigName) } return configFilenames } // extractWorktrees finds WorktreeOption in options and returns worktrees. func extractWorktrees(opts []common.Option) *worktrees.Worktrees { for _, opt := range opts { if wo, ok := opt.(common.WorktreeOption); ok { return wo.Worktrees } } return nil } // newBaseDiscovery constructs the base discovery with common immutable options. func newBaseDiscovery( opts *options.TerragruntOptions, workingDir string, configFilenames []string, runnerOpts ...common.Option, ) *discovery.Discovery { anyOpts := make([]any, len(runnerOpts)) for i, v := range runnerOpts { anyOpts[i] = v } d := discovery. NewDiscovery(workingDir). WithOptions(anyOpts...). WithConfigFilenames(configFilenames). WithRelationships(). WithDiscoveryContext(&component.DiscoveryContext{ WorkingDir: workingDir, Cmd: opts.TerraformCliArgs.First(), Args: opts.TerraformCliArgs.Tail(), }) return d } // prepareDiscovery constructs a configured discovery instance based on Terragrunt options and flags. func prepareDiscovery( opts *options.TerragruntOptions, runnerOpts ...common.Option, ) *discovery.Discovery { workingDir := resolveWorkingDir(opts) configFilenames := buildConfigFilenames(opts) d := newBaseDiscovery(opts, workingDir, configFilenames, runnerOpts...) // Apply pre-parsed filters when provided if len(opts.Filters) > 0 { d = d.WithFilters(opts.Filters) } // Apply worktrees for git filter expressions if w := extractWorktrees(runnerOpts); w != nil { d = d.WithWorktrees(w) } return d } // discoverWithRetry runs discovery and retries without exclude-by-default if zero results // are found and modules-that-include / units-reading flags are set. func discoverWithRetry( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, runnerOpts ...common.Option, ) (component.Components, error) { // Initial discovery with current excludeByDefault setting d := prepareDiscovery(opts, runnerOpts...) var discovered component.Components err := doWithTelemetry(ctx, telemetryDiscovery, map[string]any{ "working_dir": opts.WorkingDir, "terraform_command": opts.TerraformCommand, }, func(childCtx context.Context) error { var discoveryErr error discovered, discoveryErr = d.Discover(childCtx, l, opts) if discoveryErr == nil { l.Debugf("Runner pool discovery found %d configs", len(discovered)) } return discoveryErr }) if err != nil { return nil, err } return discovered, nil } // createRunner wraps runner creation with telemetry and returns the stack runner. func createRunner( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, comps component.Components, runnerOpts ...common.Option, ) (common.StackRunner, error) { var rnr common.StackRunner err := doWithTelemetry(ctx, telemetryCreation, map[string]any{ "discovered_configs": len(comps), "terraform_command": opts.TerraformCommand, }, func(childCtx context.Context) error { var err2 error rnr, err2 = NewRunnerPoolStack(childCtx, l, opts, comps, runnerOpts...) return err2 }) if err != nil { return nil, err } return rnr, nil } // checkVersionConstraints performs version constraint checks on all discovered units concurrently. // It uses errgroup to coordinate concurrent checks and returns the first error encountered. func checkVersionConstraints( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, units []*component.Unit, ) error { g, checkCtx := errgroup.WithContext(ctx) maxWorkers := min(runtime.NumCPU(), opts.Parallelism) g.SetLimit(maxWorkers) for _, unit := range units { g.Go(func() error { unitOpts, unitLogger, err := BuildUnitOpts(l, opts, unit) if err != nil { return err } return checkUnitVersionConstraints( checkCtx, l, unitOpts, unitLogger, unit, ) }) } return g.Wait() } // checkUnitVersionConstraints checks version constraints for a single unit. // It handles config parsing if needed and performs version constraint validation. func checkUnitVersionConstraints( ctx context.Context, l log.Logger, unitOpts *options.TerragruntOptions, unitLogger log.Logger, unit *component.Unit, ) error { unitConfig := unit.Config() // This is almost definitely already parsed, but we'll check just in case. if unitConfig == nil { configCtx, pctx := configbridge.NewParsingContext(ctx, l, unitOpts) pctx = pctx.WithDecodeList( config.TerragruntVersionConstraints, config.FeatureFlagsBlock, ) var err error unitConfig, err = config.PartialParseConfigFile( configCtx, pctx, l, unit.ConfigFile(), nil, ) if err != nil { return errors.Errorf("failed to parse config for unit %s: %w", unit.DisplayPath(), err) } } if !unitOpts.TFPathExplicitlySet && unitConfig.TerraformBinary != "" { unitOpts.TFPath = unitConfig.TerraformBinary } if unitLogger != nil { l = unitLogger } _, ver, impl, err := run.PopulateTFVersion(ctx, l, unitOpts.WorkingDir, unitOpts.VersionManagerFileName, configbridge.TFRunOptsFromOpts(unitOpts)) if err != nil { return errors.Errorf("failed to populate Terraform version for unit %s: %w", unit.DisplayPath(), err) } unitOpts.TerraformVersion = ver unitOpts.TofuImplementation = impl terraformVersionConstraint := run.DefaultTerraformVersionConstraint if unitConfig.TerraformVersionConstraint != "" { terraformVersionConstraint = unitConfig.TerraformVersionConstraint } if err := run.CheckTerraformVersionMeetsConstraint(unitOpts.TerraformVersion, terraformVersionConstraint); err != nil { return errors.Errorf("Terraform version check failed for unit %s: %w", unit.DisplayPath(), err) } if unitConfig.TerragruntVersionConstraint != "" { if err := run.CheckTerragruntVersionMeetsConstraint( unitOpts.TerragruntVersion, unitConfig.TerragruntVersionConstraint, ); err != nil { return errors.Errorf("Terragrunt version check failed for unit %s: %w", unit.DisplayPath(), err) } } if unitConfig.FeatureFlags != nil { for _, flag := range unitConfig.FeatureFlags { flagName := flag.Name defaultValue, err := flag.DefaultAsString() if err != nil { return errors.Errorf("failed to get default value for feature flag %s in unit %s: %w", flagName, unit.DisplayPath(), err) } if _, exists := unitOpts.FeatureFlags.Load(flagName); !exists { unitOpts.FeatureFlags.Store(flagName, defaultValue) } } } return nil } ================================================ FILE: internal/runner/runnerpool/controller.go ================================================ package runnerpool import ( "context" "sync" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/puzpuzpuz/xsync/v3" ) // UnitRunner defines a function type that executes a Unit within a given context and returns an error. type UnitRunner func(ctx context.Context, u *component.Unit) error // Controller orchestrates concurrent execution over a DAG. type Controller struct { q *queue.Queue runner UnitRunner readyCh chan struct{} unitsMap map[string]*component.Unit concurrency int } // ControllerOption is a function that modifies a Controller. type ControllerOption func(*Controller) // WithRunner sets the UnitRunner for the Controller. func WithRunner(runner UnitRunner) ControllerOption { return func(dr *Controller) { dr.runner = runner } } // WithMaxConcurrency sets the concurrency for the Controller. func WithMaxConcurrency(concurrency int) ControllerOption { return func(dr *Controller) { if concurrency <= 0 { concurrency = 1 } dr.concurrency = concurrency } } // NewController creates a new Controller with the given options and a pre-built queue. func NewController(q *queue.Queue, units []*component.Unit, opts ...ControllerOption) *Controller { dr := &Controller{ q: q, readyCh: make(chan struct{}, 1), // buffered to avoid blocking concurrency: options.DefaultParallelism, } // Map to link runner Units and Queue Entries unitsMap := make(map[string]*component.Unit) for _, u := range units { if u != nil && u.Path() != "" { unitsMap[u.Path()] = u } } dr.unitsMap = unitsMap for _, opt := range opts { opt(dr) } if dr.q == nil { // If the queue was not set, create an empty queue dr.q = &queue.Queue{Entries: []*queue.Entry{}} } return dr } // Run executes the Queue return error summarizing all entries that failed to run. func (dr *Controller) Run(ctx context.Context, l log.Logger) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_controller", map[string]any{ "total_tasks": len(dr.q.Entries), "concurrency": dr.concurrency, "fail_fast": dr.q.FailFast, "ignore_dependency_order": dr.q.IgnoreDependencyOrder, }, func(childCtx context.Context) error { var ( wg sync.WaitGroup sem = make(chan struct{}, dr.concurrency) results = xsync.NewMapOf[string, error]() ) if dr.runner == nil { return errors.Errorf("Runner Pool Controller: runner is not set, cannot run") } l.Debugf("Runner Pool Controller: starting with %d tasks, concurrency %d", len(dr.q.Entries), dr.concurrency) // Initial signal to start scheduling select { case dr.readyCh <- struct{}{}: default: } for { readyEntries := dr.q.GetReadyWithDependencies(l) l.Debugf("Runner Pool Controller: found %d readyEntries tasks", len(readyEntries)) for _, e := range readyEntries { // log debug which entry is running l.Debugf("Runner Pool Controller: running %s", e.Component.Path()) dr.q.SetEntryStatus(e, queue.StatusRunning) sem <- struct{}{} wg.Add(1) go func(ent *queue.Entry) { defer func() { <-sem wg.Done() select { case dr.readyCh <- struct{}{}: default: } }() unit := dr.unitsMap[ent.Component.Path()] if unit == nil { err := errors.Errorf("unit for path %s not found in discovered units", ent.Component.Path()) l.Errorf("Runner Pool Controller: unit for path %s not found in discovered units, skipping execution", ent.Component.Path()) dr.q.FailEntry(ent) results.Store(ent.Component.Path(), err) return } err := dr.runner(childCtx, unit) results.Store(ent.Component.Path(), err) if err != nil { l.Debugf("Runner Pool Controller: %s failed", ent.Component.Path()) dr.q.FailEntry(ent) return } l.Debugf("Runner Pool Controller: %s succeeded", ent.Component.Path()) dr.q.SetEntryStatus(ent, queue.StatusSucceeded) }(e) } if dr.q.Finished() { break } select { case <-dr.readyCh: case <-childCtx.Done(): wg.Wait() return nil } } wg.Wait() // Collect errors from results map and check for errors errCollector := &errors.MultiError{} for _, entry := range dr.q.Entries { if err, ok := results.Load(entry.Component.Path()); ok { if err == nil { continue } errCollector = errCollector.Append(err) continue } if entry.Status == queue.StatusEarlyExit { failedDep := findFailedDependency(entry, dr.q) errCollector = errCollector.Append(NewUnitEarlyExitError(entry.Component.Path(), failedDep)) } if entry.Status == queue.StatusFailed { errCollector = errCollector.Append(NewUnitFailedError(entry.Component.Path())) } } return errCollector.ErrorOrNil() }) } ================================================ FILE: internal/runner/runnerpool/controller_test.go ================================================ package runnerpool_test import ( "context" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" ) // buildComponentUnits creates component units and wires dependencies based on path relationships. func buildComponentUnits(paths []string, depMap map[string][]string) []*component.Unit { unitMap := make(map[string]*component.Unit) // First pass: create units for _, path := range paths { unitMap[path] = component.NewUnit(path) } // Second pass: wire dependencies for path, deps := range depMap { unit := unitMap[path] for _, depPath := range deps { if depUnit, ok := unitMap[depPath]; ok { unit.AddDependency(depUnit) } } } // Collect in order units := make([]*component.Unit, 0, len(paths)) for _, path := range paths { units = append(units, unitMap[path]) } return units } func TestRunnerPool_LinearDependency(t *testing.T) { t.Parallel() // A -> B -> C units := buildComponentUnits( []string{"A", "B", "C"}, map[string][]string{ "B": {"A"}, "C": {"B"}, }, ) components := make(component.Components, len(units)) for i, u := range units { components[i] = u } runner := func(ctx context.Context, u *component.Unit) error { return nil } q, err := queue.NewQueue(components) require.NoError(t, err) dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(2), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.NoError(t, err) } func TestRunnerPool_ParallelExecution(t *testing.T) { t.Parallel() // A // / \ // B C units := buildComponentUnits( []string{"A", "B", "C"}, map[string][]string{ "B": {"A"}, "C": {"A"}, }, ) runner := func(ctx context.Context, u *component.Unit) error { return nil } components := make(component.Components, len(units)) for i, u := range units { components[i] = u } q, err := queue.NewQueue(components) require.NoError(t, err) dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(2), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.NoError(t, err) } func TestRunnerPool_FailFast(t *testing.T) { t.Parallel() // A -> B -> C units := buildComponentUnits( []string{"A", "B", "C"}, map[string][]string{ "B": {"A"}, "C": {"B"}, }, ) runner := func(ctx context.Context, u *component.Unit) error { if u.Path() == "A" { return errors.New("unit A failed") } return nil } components := make(component.Components, len(units)) for i, u := range units { components[i] = u } q, err := queue.NewQueue(components) require.NoError(t, err) q.FailFast = true dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(2), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.Error(t, err) for _, want := range []string{"unit A failed", "Unit 'B' did not run", "Unit 'C' did not run"} { assert.Contains(t, err.Error(), want, "Expected error message '%s' in errors", want) } } // Helper to build a more complex dependency graph: // // A // / \ // B C // / \ // // D E func buildComplexUnits() []*component.Unit { return buildComponentUnits( []string{"A", "B", "C", "D", "E"}, map[string][]string{ "B": {"A"}, "C": {"A"}, "D": {"B"}, "E": {"B"}, }, ) } func TestRunnerPool_ComplexDependency_BFails(t *testing.T) { t.Parallel() units := buildComplexUnits() runner := func(ctx context.Context, u *component.Unit) error { if u.Path() == "B" { return errors.New("unit B failed") } return nil } components := make(component.Components, len(units)) for i, u := range units { components[i] = u } q, err := queue.NewQueue(components) require.NoError(t, err) dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(8), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.Error(t, err) for _, want := range []string{"unit B failed", "Unit 'D' did not run", "Unit 'E' did not run"} { assert.Contains(t, err.Error(), want, "Expected error message '%s' in errors", want) } } func TestRunnerPool_ComplexDependency_AFails_FailFast(t *testing.T) { t.Parallel() units := buildComplexUnits() runner := func(ctx context.Context, u *component.Unit) error { if u.Path() == "A" { return errors.New("unit A failed") } return nil } components := make(component.Components, len(units)) for i, u := range units { components[i] = u } q, err := queue.NewQueue(components) require.NoError(t, err) q.FailFast = true dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(8), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.Error(t, err) for _, want := range []string{ "unit A failed", "Unit 'B' did not run", "Unit 'C' did not run", "Unit 'D' did not run", "Unit 'E' did not run", } { assert.Contains(t, err.Error(), want, "Expected error message '%s' in errors", want) } } func TestRunnerPool_ComplexDependency_BFails_FailFast(t *testing.T) { t.Parallel() units := buildComplexUnits() runner := func(ctx context.Context, u *component.Unit) error { if u.Path() == "B" { return errors.New("unit B failed") } return nil } components := make(component.Components, len(units)) for i, u := range units { components[i] = u } q, err := queue.NewQueue(components) require.NoError(t, err) q.FailFast = true dagRunner := runnerpool.NewController( q, units, runnerpool.WithRunner(runner), runnerpool.WithMaxConcurrency(8), ) err = dagRunner.Run(t.Context(), logger.CreateLogger()) require.Error(t, err) for _, want := range []string{"unit B failed", "Unit 'D' did not run", "Unit 'E' did not run"} { assert.Contains(t, err.Error(), want, "Expected error message '%s' in errors", want) } } ================================================ FILE: internal/runner/runnerpool/errors.go ================================================ package runnerpool import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/queue" ) // UnitEarlyExitError is an error type for units that didn't run due to dependency failure. type UnitEarlyExitError struct { UnitPath string FailedDependency string // The dependency that caused the early exit (optional) } func (e UnitEarlyExitError) Error() string { if e.FailedDependency != "" { return fmt.Sprintf("Unit '%s' did not run due to a failure in '%s'", e.UnitPath, e.FailedDependency) } return fmt.Sprintf("Unit '%s' did not run due to an earlier failure", e.UnitPath) } // NewUnitEarlyExitError creates a new UnitEarlyExitError. func NewUnitEarlyExitError(unitPath, failedDep string) error { return errors.New(UnitEarlyExitError{ UnitPath: unitPath, FailedDependency: failedDep, }) } // UnitFailedError is an error type for units that failed during execution. type UnitFailedError struct { UnitPath string } func (e UnitFailedError) Error() string { return fmt.Sprintf("Unit '%s' encountered an error during its run", e.UnitPath) } // NewUnitFailedError creates a new UnitFailedError. func NewUnitFailedError(unitPath string) error { return errors.New(UnitFailedError{UnitPath: unitPath}) } // findFailedDependency finds the first failed dependency for a given entry. func findFailedDependency(entry *queue.Entry, q *queue.Queue) string { for _, dep := range entry.Component.Dependencies() { for _, e := range q.Entries { if e.Component.Path() == dep.Path() { if e.Status == queue.StatusFailed { return dep.Path() } } } } return "" } ================================================ FILE: internal/runner/runnerpool/graph_fallback_test.go ================================================ package runnerpool_test import ( "context" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" thlogger "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) // Test that the runner-level fallback (WithGraphTarget) limits the stack to target + dependents, // and that this matches the discovery-based graph filter behavior when the filter experiment is enabled. func TestGraphFallbackMatchesFilterExperiment(t *testing.T) { t.Parallel() ctx := context.Background() l := thlogger.CreateLogger() tmpDir := helpers.TmpDirWOSymlinks(t) // Make tmpDir a git repository so graph root detection works consistently helpers.CreateGitRepo(t, tmpDir) // Create a simple dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") appDir := filepath.Join(tmpDir, "app") for _, dir := range []string{vpcDir, dbDir, appDir} { require.NoError(t, os.MkdirAll(dir, 0o755)) } // Minimal terragrunt.hcl files to express dependencies require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(``), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dbDir, "terragrunt.hcl"), []byte(` dependency "vpc" { config_path = "../vpc" } `), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(` dependency "db" { config_path = "../db" } `), 0o644)) // Ensure each unit directory has at least one Terraform file to avoid being skipped during unit resolution. for _, dir := range []string{vpcDir, dbDir, appDir} { require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(""), 0o644)) } // Path set we expect when targeting vpc: {vpc, db, app} expected := []string{vpcDir, dbDir, appDir} // Path 1: experiment ON, use discovery filter optsOn := options.NewTerragruntOptions() optsOn.WorkingDir = vpcDir optsOn.RootWorkingDir = tmpDir // Enable the filter-flag experiment require.NoError(t, optsOn.Experiments.EnableExperiment("filter-flag")) // Inject graph filter for dependents of target parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{`...{` + vpcDir + `}`}) require.NoError(t, parseErr) optsOn.Filters = parsedFilters // Build runner runnerOn, err := runnerpool.Build(ctx, l, optsOn) require.NoError(t, err) // Collect unit paths onPaths := make([]string, 0, len(runnerOn.GetStack().Units)) for _, u := range runnerOn.GetStack().Units { onPaths = append(onPaths, u.Path()) } // Path 2: experiment OFF, use fallback option optsOff := options.NewTerragruntOptions() optsOff.WorkingDir = vpcDir optsOff.RootWorkingDir = tmpDir // No filter queries; rely on fallback graph target option runnerOff, err := runnerpool.Build(ctx, l, optsOff, discovery.WithGraphTarget(vpcDir)) require.NoError(t, err) offPaths := make([]string, 0, len(runnerOff.GetStack().Units)) for _, u := range runnerOff.GetStack().Units { offPaths = append(offPaths, u.Path()) } // Both paths should include exactly target + dependents (order not guaranteed) assert.ElementsMatch(t, expected, onPaths) assert.ElementsMatch(t, expected, offPaths) assert.ElementsMatch(t, onPaths, offPaths) } ================================================ FILE: internal/runner/runnerpool/helpers_test.go ================================================ package runnerpool_test import ( "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" thlogger "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func TestCloneUnitOptions_WithStackOpts(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") stackOpts, err := options.NewTerragruntOptionsForTest(filepath.Join(tmpDir, "stack", "terragrunt.hcl")) require.NoError(t, err) unit := component.NewUnit(tmpDir) l := thlogger.CreateLogger() opts, logger, err := runnerpool.CloneUnitOptions(stackOpts, unit, configPath, "", l) require.NoError(t, err) require.NotNil(t, opts) assert.NotNil(t, logger) assert.Equal(t, configPath, opts.OriginalTerragruntConfigPath) assert.NotEmpty(t, opts.DownloadDir) } ================================================ FILE: internal/runner/runnerpool/runner.go ================================================ // Package runnerpool provides a runner implementation based on a pool pattern for executing multiple units concurrently. package runnerpool import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/component" tgerrors "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/gruntwork-io/terragrunt/pkg/options" ) // Runner implements the Stack interface for runner pool execution. type Runner struct { Stack *component.Stack queue *queue.Queue } // CloneUnitOptions clones TerragruntOptions for a specific unit. // It handles CloneWithConfigPath, per-unit DownloadDir fallback, and OriginalTerragruntConfigPath. // Returns the cloned options and logger, or the original logger if stackOpts is nil. func CloneUnitOptions( stackOpts *options.TerragruntOptions, unit *component.Unit, canonicalConfigPath string, stackDefaultDownloadDir string, l log.Logger, ) (*options.TerragruntOptions, log.Logger, error) { clonedLogger, clonedOpts, err := stackOpts.CloneWithConfigPath(l, canonicalConfigPath) if err != nil { return nil, nil, err } // Override logger prefix with display path (relative to discovery context) for cleaner logs // unless --log-show-abs-paths is set if !stackOpts.Writers.LogShowAbsPaths { clonedLogger = clonedLogger.WithField(placeholders.WorkDirKeyName, unit.DisplayPath()) } // Use a per-unit default download directory when the stack is using its own default // (i.e., no custom download dir was provided). This mirrors unit resolver behaviour // so each unit caches to its own .terragrunt-cache next to the config. if clonedOpts.DownloadDir == "" || (stackDefaultDownloadDir != "" && clonedOpts.DownloadDir == stackDefaultDownloadDir) { _, unitDefaultDownloadDir := util.DefaultWorkingAndDownloadDirs(canonicalConfigPath) clonedOpts.DownloadDir = unitDefaultDownloadDir } clonedOpts.OriginalTerragruntConfigPath = canonicalConfigPath return clonedOpts, clonedLogger, nil } // BuildUnitOpts creates per-unit opts and logger for a single unit on demand. // It computes the canonical config path, clones options, applies source overrides, // and transfers discovery context command/args. func BuildUnitOpts(l log.Logger, stackOpts *options.TerragruntOptions, unit *component.Unit) (*options.TerragruntOptions, log.Logger, error) { var stackDefaultDownloadDir string if stackOpts != nil { _, stackDefaultDownloadDir = util.DefaultWorkingAndDownloadDirs(stackOpts.TerragruntConfigPath) } // Compute config path from already-canonical unit.Path() + unit.ConfigFile() configPath := unit.Path() if !strings.HasSuffix(configPath, ".hcl") && !strings.HasSuffix(configPath, ".json") { fileName := config.DefaultTerragruntConfigPath if unit.ConfigFile() != "" { fileName = unit.ConfigFile() } configPath = filepath.Join(unit.Path(), fileName) } // Clone options for this unit unitOpts, unitLogger, err := CloneUnitOptions(stackOpts, unit, configPath, stackDefaultDownloadDir, l) if err != nil { return nil, nil, err } // If --source is provided, compute the per-unit source if stackOpts != nil && stackOpts.Source != "" { unitConfig := unit.Config() if unitConfig != nil { unitSource, sourceErr := config.GetTerragruntSourceForModule( stackOpts.Source, configPath, unitConfig, ) if sourceErr != nil { return nil, nil, tgerrors.Errorf("failed to compute source for unit %s: %w", unit.DisplayPath(), sourceErr) } if unitSource != "" { unitOpts.Source = unitSource } } } // Transfer discovery context command and args to unit options if available if discoveryCtx := unit.DiscoveryContext(); discoveryCtx != nil { if discoveryCtx.Cmd != "" { unitOpts.TerraformCommand = discoveryCtx.Cmd } if len(discoveryCtx.Args) > 0 { terraformCliArgs := make([]string, 0, 1+len(discoveryCtx.Args)) if discoveryCtx.Cmd != "" { terraformCliArgs = append(terraformCliArgs, discoveryCtx.Cmd) } terraformCliArgs = append(terraformCliArgs, discoveryCtx.Args...) unitOpts.TerraformCliArgs = iacargs.New(terraformCliArgs...) } } return unitOpts, unitLogger, nil } // syncUnitCliArgs applies CLI argument synchronization for a single unit. // It merges/clones flags from stackOpts and computes and appends the plan file if needed. func syncUnitCliArgs(l log.Logger, stackOpts *options.TerragruntOptions, unitOpts *options.TerragruntOptions, unit *component.Unit) { discoveryCtx := unit.DiscoveryContext() if discoveryCtx != nil && len(discoveryCtx.Args) > 0 { // Merge stack-level flags that aren't already present unitOpts.TerraformCliArgs.MergeFlags(stackOpts.TerraformCliArgs) } else { unitOpts.TerraformCliArgs = stackOpts.TerraformCliArgs.Clone() } planFile := unit.PlanFile(stackOpts.RootWorkingDir, stackOpts.OutputFolder, unitOpts.JSONOutputFolder, unitOpts.TerraformCommand) if planFile != "" { l.Debugf("Using output file %s for unit %s", planFile, unitOpts.TerragruntConfigPath) // Check if plan file already exists in args if unitOpts.TerraformCliArgs.HasPlanFile() { return } if unitOpts.TerraformCommand == tf.CommandNamePlan { // for plan command add -out= to the terraform cli args unitOpts.TerraformCliArgs.AppendFlag("-out=" + planFile) return } unitOpts.TerraformCliArgs.AppendArgument(planFile) } } // checkLocalStateWithGitRefs checks if any unit has a Git ref in its discovery context // but no remote state configuration, and logs a warning if so. func checkLocalStateWithGitRefs(l log.Logger, units []*component.Unit) { for _, unit := range units { discoveryCtx := unit.DiscoveryContext() if discoveryCtx == nil { continue } if discoveryCtx.Ref == "" { continue } unitConfig := unit.Config() if unitConfig == nil { continue } if unitConfig.RemoteState == nil || (unitConfig.RemoteState.Config != nil && unitConfig.RemoteState.BackendName == "local") { l.Warnf( "One or more units discovered using Git-based filter expressions (e.g. [HEAD~1...HEAD]) do not have a remote_state configuration. This may result in unexpected outcomes, such as outputs for dependencies returning empty. It is strongly recommended to use remote state when working with Git-based filter expressions.", ) return } } } // NewRunnerPoolStack creates a new stack from discovered units. func NewRunnerPoolStack( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, discovered component.Components, runnerOpts ...common.Option, ) (common.StackRunner, error) { // Filter out Stack components - we only want Unit components // Stack components (terragrunt.stack.hcl files) are for stack generation, not execution nonStackComponents := make(component.Components, 0, len(discovered)) for _, c := range discovered { if _, ok := c.(*component.Unit); ok { nonStackComponents = append(nonStackComponents, c) } } if len(nonStackComponents) == 0 { l.Warnf("No units discovered. Creating an empty runner.") stack := component.NewStack(opts.WorkingDir) rnr := &Runner{ Stack: stack, } // Create an empty queue q, queueErr := queue.NewQueue(component.Components{}) if queueErr != nil { return nil, queueErr } rnr.queue = q return rnr.WithOptions(runnerOpts...), nil } // Initialize stack; queue will be constructed after resolving units so we can filter excludes first. stack := component.NewStack(opts.WorkingDir) rnr := &Runner{ Stack: stack, } // Apply options (including report) BEFORE resolving units so that // the report is available during unit resolution for tracking exclusions rnr = rnr.WithOptions(runnerOpts...) // Resolve units from discovery units := make([]*component.Unit, 0, len(nonStackComponents)) for _, c := range nonStackComponents { unit, ok := c.(*component.Unit) if !ok { continue } if unit.DiscoveryContext() != nil && unit.Config() == nil { l.Debugf("Unit %s has no config from discovery", unit.DisplayPath()) } units = append(units, unit) } // Check for units with Git refs but no remote state configuration checkLocalStateWithGitRefs(l, units) rnr.Stack.Units = units if opts.TerraformCliArgs.IsDestroyCommand(opts.TerraformCommand) { applyPreventDestroyExclusions(l, units) } // Apply filter-allow-destroy exclusions for plan and apply commands if opts.TerraformCommand == tf.CommandNamePlan || opts.TerraformCommand == tf.CommandNameApply { applyFilterAllowDestroyExclusions(l, opts, units) } // Build queue from resolved units (which have canonical absolute paths). // Filter out excluded units so they are not shown in lists or scheduled. filtered := filterUnitsToComponents(units) q, queueErr := queue.NewQueue(filtered) if queueErr != nil { return nil, queueErr } rnr.queue = q return rnr, nil } // filterUnitsToComponents converts resolved units to Components. // Excluded units that are assumed already applied are kept in the queue // so their dependents can run (they will be immediately marked as succeeded). // Only truly excluded units are filtered out. func filterUnitsToComponents(units []*component.Unit) component.Components { result := make(component.Components, 0, len(units)) for _, u := range units { if u.Excluded() { // Truly excluded - skip entirely continue } result = append(result, u) } return result } // Run executes the stack according to TerragruntOptions and returns the first // error (or a joined error) once execution is finished. func (rnr *Runner) Run(ctx context.Context, l log.Logger, stackOpts *options.TerragruntOptions, r *report.Report) error { terraformCmd := stackOpts.TerraformCommand if stackOpts.OutputFolder != "" { for _, u := range rnr.Stack.Units { planFile := u.OutputFile(stackOpts.RootWorkingDir, stackOpts.OutputFolder) if err := os.MkdirAll(filepath.Dir(planFile), os.ModePerm); err != nil { return err } } } // Mutate stackOpts CLI args at the top level - these get cloned into per-unit opts later if slices.Contains(config.TerraformCommandsNeedInput, terraformCmd) { stackOpts.TerraformCliArgs.InsertFlag(0, "-input=false") } needsCliSync := false isPlan := false switch terraformCmd { case tf.CommandNameApply, tf.CommandNameDestroy: if stackOpts.RunAllAutoApprove { stackOpts.TerraformCliArgs.InsertFlag(0, "-auto-approve") } needsCliSync = true case tf.CommandNameShow: needsCliSync = true case tf.CommandNamePlan: isPlan = true needsCliSync = true } if slices.Contains(config.TerraformCommandsNeedInput, terraformCmd) { needsCliSync = true } // Pre-allocate plan error buffers keyed by unit path var planErrorBuffers map[string]*bytes.Buffer if isPlan { planErrorBuffers = make(map[string]*bytes.Buffer, len(rnr.Stack.Units)) for _, u := range rnr.Stack.Units { planErrorBuffers[u.Path()] = &bytes.Buffer{} } defer rnr.summarizePlanAllErrors(l, planErrorBuffers) } // Emit report entries for excluded units that haven't been reported yet. // Units excluded by CLI flags or exclude blocks are already reported during unit resolution, // but we still need to report units excluded by other mechanisms (e.g., external dependencies). if r != nil { for _, u := range rnr.Stack.Units { if u.Excluded() { unitPath := u.Path() // Pass the discovery context fields for worktree scenarios var ensureOpts []report.EndOption if discoveryCtx := u.DiscoveryContext(); discoveryCtx != nil { ensureOpts = append( ensureOpts, report.WithDiscoveryWorkingDir(discoveryCtx.WorkingDir), report.WithRef(discoveryCtx.Ref), report.WithCmd(discoveryCtx.Cmd), report.WithArgs(discoveryCtx.Args), ) } run, err := r.EnsureRun(l, unitPath, ensureOpts...) if err != nil { l.Errorf("Error ensuring run for unit %s: %v", unitPath, err) continue } // Only report exclusion if it hasn't been reported yet // Units excluded by --queue-exclude-dir or exclude blocks are already reported // during unit resolution with the correct reason if run.Result == "" { // Determine the reason for exclusion // External dependencies that are assumed already applied are excluded with --queue-exclude-external reason := report.ReasonExcludeBlock if err := r.EndRun( l, run.Path, report.WithResult(report.ResultExcluded), report.WithReason(reason), ); err != nil { l.Errorf("Error ending run for unit %s: %v", unitPath, err) } } } } } task := func(ctx context.Context, u *component.Unit) error { // Build per-unit opts and logger on demand unitOpts, unitLogger, err := BuildUnitOpts(l, stackOpts, u) if err != nil { return tgerrors.Errorf("failed to build opts for unit %s: %w", u.Path(), err) } // Sync CLI args from stackOpts into unit opts if needsCliSync { syncUnitCliArgs(l, stackOpts, unitOpts, u) } // Wrap ErrWriter with plan error buffer for plan commands if isPlan { if buf := planErrorBuffers[u.Path()]; buf != nil { unitOpts.Writers.ErrWriter = io.MultiWriter(buf, unitOpts.Writers.ErrWriter) } } return telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_task", map[string]any{ "terraform_command": unitOpts.TerraformCommand, "terraform_cli_args": unitOpts.TerraformCliArgs, "working_dir": unitOpts.WorkingDir, "terragrunt_config_path": unitOpts.TerragruntConfigPath, }, func(childCtx context.Context) error { // Wrap the writer to buffer unit-scoped output unitWriter := NewUnitWriter(unitOpts.Writers.Writer) unitOpts.Writers.Writer = unitWriter unitRunner := common.NewUnitRunner(u) // Get credentials BEFORE config parsing — sops_decrypt_file() and // get_aws_account_id() in locals need auth-provider credentials // available in opts.Env during HCL evaluation. // See https://github.com/gruntwork-io/terragrunt/issues/5515 credsGetter, err := creds.ObtainCredsForParsing(childCtx, unitLogger, unitOpts.AuthProviderCmd, unitOpts.Env, configbridge.ShellRunOptsFromOpts(unitOpts)) if err != nil { return err } parseCtx, pctx := configbridge.NewParsingContext(childCtx, unitLogger, unitOpts) cfg, err := config.ReadTerragruntConfig( parseCtx, unitLogger, pctx, pctx.ParserOptions, ) if err != nil { return err } runCfg := cfg.ToRunConfig(unitLogger) err = unitRunner.Run( childCtx, unitLogger, unitOpts, r, runCfg, credsGetter, ) // Flush any remaining buffered output if flushErr := unitWriter.Flush(); flushErr != nil && err == nil { err = flushErr } return err }) } rnr.queue.FailFast = stackOpts.FailFast rnr.queue.IgnoreDependencyOrder = stackOpts.IgnoreDependencyOrder // Allow continuing the queue when dependencies fail if requested via CLI rnr.queue.IgnoreDependencyErrors = stackOpts.IgnoreDependencyErrors controller := NewController( rnr.queue, rnr.Stack.Units, WithRunner(task), WithMaxConcurrency(stackOpts.Parallelism), ) err := controller.Run(ctx, l) // Emit report entries for early exit and failed units after controller completes if r != nil { // Build a quick lookup of queue entry status by path to avoid nested scans statusByPath := make(map[string]queue.Status, len(rnr.queue.Entries)) for _, qe := range rnr.queue.Entries { statusByPath[qe.Component.Path()] = qe.Status } for _, entry := range rnr.queue.Entries { // Handle both early exit and failed units to ensure they're in the report if entry.Status == queue.StatusEarlyExit || entry.Status == queue.StatusFailed { unit := rnr.Stack.FindUnitByPath(entry.Component.Path()) if unit == nil { l.Warnf("Could not find unit for entry: %s", entry.Component.Path()) continue } unitPath := unit.Path() // Pass the discovery context fields for worktree scenarios var ensureOpts []report.EndOption if discoveryCtx := unit.DiscoveryContext(); discoveryCtx != nil { ensureOpts = append( ensureOpts, report.WithDiscoveryWorkingDir(discoveryCtx.WorkingDir), report.WithRef(discoveryCtx.Ref), report.WithCmd(discoveryCtx.Cmd), report.WithArgs(discoveryCtx.Args), ) } run, reportErr := r.EnsureRun(l, unitPath, ensureOpts...) if reportErr != nil { l.Errorf("Error ensuring run for unit %s: %v", unitPath, reportErr) continue } // Find the immediate failed or early-exited ancestor to set as cause // If a dependency failed, use it; otherwise if a dependency exited early, use it var failedAncestor string for _, dep := range entry.Component.Dependencies() { status := statusByPath[dep.Path()] if status == queue.StatusFailed { failedAncestor = filepath.Base(dep.Path()) break } if status == queue.StatusEarlyExit && failedAncestor == "" { // Use early exit dependency as fallback failedAncestor = filepath.Base(dep.Path()) } } switch entry.Status { //nolint:exhaustive case queue.StatusEarlyExit: endOpts := []report.EndOption{ report.WithResult(report.ResultEarlyExit), report.WithReason(report.ReasonAncestorError), } if failedAncestor != "" { endOpts = append(endOpts, report.WithCauseAncestorExit(failedAncestor)) } if endErr := r.EndRun(l, run.Path, endOpts...); endErr != nil { l.Errorf("Error ending run for early exit unit %s: %v", unitPath, endErr) } case queue.StatusFailed: // For failed units, check if they failed due to dependency errors // If so, mark them as early exit; otherwise mark as failed endOpts := []report.EndOption{ report.WithResult(report.ResultFailed), report.WithReason(report.ReasonRunError), } if failedAncestor != "" { // If a dependency failed, treat this as early exit due to ancestor error endOpts = []report.EndOption{ report.WithResult(report.ResultEarlyExit), report.WithReason(report.ReasonAncestorError), report.WithCauseAncestorExit(failedAncestor), } } if endErr := r.EndRun(l, run.Path, endOpts...); endErr != nil { l.Errorf("Error ending run for failed unit %s: %v", unitPath, endErr) } } } } } return err } // LogUnitDeployOrder logs the order of units to be processed for a given Terraform command. func (rnr *Runner) LogUnitDeployOrder(l log.Logger, terraformCmd string, isDestroy bool, showAbsPaths bool) error { outStr := fmt.Sprintf( "Unit queue will be processed for %s in this order:\n", terraformCmd, ) // For destroy commands, reflect the actual processing order (reverse of apply order). // NOTE: This is display-only. The queue scheduler dynamically handles destroy order via // IsUp() checks - dependents must complete before their dependencies are processed. entries := slices.Clone(rnr.queue.Entries) if isDestroy { slices.Reverse(entries) } var outStrSb strings.Builder for _, unit := range entries { unitPath := unit.Component.DisplayPath() if showAbsPaths { unitPath = unit.Component.Path() } fmt.Fprintf(&outStrSb, "- Unit %s\n", unitPath) } outStr += outStrSb.String() l.Info(outStr) return nil } // JSONUnitDeployOrder returns the order of units to be processed for a given Terraform command in JSON format. func (rnr *Runner) JSONUnitDeployOrder(isDestroy bool, showAbsPaths bool) (string, error) { entries := slices.Clone(rnr.queue.Entries) if isDestroy { slices.Reverse(entries) } orderedUnits := make([]string, 0, len(entries)) for _, unit := range entries { unitPath := unit.Component.DisplayPath() if showAbsPaths { unitPath = unit.Component.Path() } orderedUnits = append(orderedUnits, unitPath) } j, err := json.MarshalIndent(orderedUnits, "", " ") if err != nil { return "", err } return string(j), nil } // ListStackDependentUnits returns a map of units and their dependent units in the stack. func (rnr *Runner) ListStackDependentUnits() map[string][]string { dependentUnits := make(map[string][]string) for _, unit := range rnr.queue.Entries { if len(unit.Component.Dependencies()) != 0 { for _, dep := range unit.Component.Dependencies() { dependentUnits[dep.Path()] = util.RemoveDuplicates(append(dependentUnits[dep.Path()], unit.Component.Path())) } } } for { noUpdates := true for unit, dependents := range dependentUnits { for _, dependent := range dependents { initialSize := len(dependentUnits[unit]) list := util.RemoveDuplicates(append(dependentUnits[unit], dependentUnits[dependent]...)) list = slices.DeleteFunc(list, func(path string) bool { return path == unit }) dependentUnits[unit] = list if initialSize != len(dependentUnits[unit]) { noUpdates = false } } } if noUpdates { break } } return dependentUnits } // summarizePlanAllErrors summarizes all errors encountered during the plan phase across all units in the stack. func (rnr *Runner) summarizePlanAllErrors(l log.Logger, errorStreams map[string]*bytes.Buffer) { for _, unit := range rnr.Stack.Units { buf := errorStreams[unit.Path()] if buf == nil { continue } output := buf.String() if len(output) == 0 { // We get Finished buffer if runner execution completed without errors, so skip that to avoid logging too much continue } if strings.Contains(output, "Error running plan:") && strings.Contains(output, ": Resource 'data.terraform_remote_state.") { var dependenciesMsg string if len(unit.Dependencies()) > 0 { cfg := unit.Config() if cfg != nil && cfg.Dependencies != nil && len(cfg.Dependencies.Paths) > 0 { dependenciesMsg = fmt.Sprintf(" contains dependencies to %v and", cfg.Dependencies.Paths) } else { dependenciesMsg = " contains dependencies and" } } l.Infof("%v%v refers to remote State "+ "you may have to apply your changes in the dependencies prior running terragrunt run --all plan.\n", unit.Path(), dependenciesMsg, ) } } } // FilterDiscoveredUnits removes configs for units flagged as excluded and prunes dependencies // that point to excluded units. This keeps the execution queue and any user-facing listings // free from units not intended to run. // // Inputs: // - discovered: raw discovery results (paths and dependency edges) // - units: resolved units (slice), where exclude rules have already been applied // // Behavior: // - A config is included only if there's a corresponding unit and it is not excluded. // - For each included config, its Dependencies list is filtered to only include included configs. // - The function returns a new slice with shallow-copied entries so the original discovery // results remain unchanged. func FilterDiscoveredUnits(discovered component.Components, units []*component.Unit) component.Components { // Build allowlist from non-excluded unit paths (already canonical from discovery) allowed := make(map[string]struct{}, len(units)) for _, u := range units { if !u.Excluded() { allowed[u.Path()] = struct{}{} } } // First pass: keep only allowed configs and prune their dependencies to allowed ones // NOTE: Unit paths should already be canonical after discovery filtered := make(component.Components, 0, len(discovered)) present := make(map[string]*component.Unit, len(discovered)) for _, c := range discovered { unit, ok := c.(*component.Unit) if !ok { continue } // Path should already be canonical from discovery unitPath := unit.Path() if _, ok := allowed[unitPath]; !ok { // Drop configs that map to excluded/missing units continue } // Create new unit with the path (already canonical) copyCfg := component.NewUnit(unitPath) copyCfg.SetDiscoveryContext(unit.DiscoveryContext()) copyCfg.SetReading(unit.Reading()...) if unit.External() { copyCfg.SetExternal() } if len(unit.Dependencies()) > 0 { for _, dep := range unit.Dependencies() { // Dependency paths should also be canonical depPath := dep.Path() if _, ok := allowed[depPath]; ok { // Create dependency with the path depCfg := component.NewUnit(depPath) copyCfg.AddDependency(depCfg) } } } filtered = append(filtered, copyCfg) present[copyCfg.Path()] = copyCfg } // Ensure every allowed unit exists in the filtered set, even if discovery didn't include it (or it was pruned) for _, u := range units { if u.Excluded() { continue } if _, ok := present[u.Path()]; ok { continue } // Create a minimal discovered config for the missing unit copyCfg := component.NewUnit(u.Path()) filtered = append(filtered, copyCfg) present[u.Path()] = copyCfg } // Augment dependencies from resolved units to ensure DAG edges are complete for _, u := range units { if u.Excluded() { continue } cfg := present[u.Path()] if cfg == nil { continue } // Build a set of existing dependency paths on cfg to avoid duplicates existing := make(map[string]struct{}, len(cfg.Dependencies())) for _, dep := range cfg.Dependencies() { existing[dep.Path()] = struct{}{} } // Add any missing allowed dependencies from the resolved unit graph for _, dep := range u.Dependencies() { depUnit, okDep := dep.(*component.Unit) if !okDep || depUnit == nil { continue } if _, allowedOK := allowed[depUnit.Path()]; !allowedOK { continue } if _, existsOK := existing[depUnit.Path()]; existsOK { continue } // Ensure the dependency config exists in the filtered set depCfg, presentOK := present[depUnit.Path()] if !presentOK { depCfg = component.NewUnit(depUnit.Path()) filtered = append(filtered, depCfg) present[depUnit.Path()] = depCfg } cfg.AddDependency(depCfg) } } return filtered } // WithOptions updates the stack with the provided options. func (rnr *Runner) WithOptions(opts ...common.Option) *Runner { for _, opt := range opts { opt.Apply(rnr) } return rnr } // GetStack returns the stack associated with the runner. func (rnr *Runner) GetStack() *component.Stack { return rnr.Stack } // applyPreventDestroyExclusions excludes units with prevent_destroy=true and their dependencies // from being destroyed. This prevents accidental destruction of protected infrastructure. func applyPreventDestroyExclusions(l log.Logger, units []*component.Unit) { // First pass: identify units with prevent_destroy=true protectedUnits := make(map[string]bool) for _, unit := range units { cfg := unit.Config() if cfg != nil && cfg.PreventDestroy != nil && *cfg.PreventDestroy { protectedUnits[unit.Path()] = true unit.SetExcluded(true) l.Debugf("Unit %s is protected by prevent_destroy flag", unit.Path()) } } if len(protectedUnits) == 0 { return } // Second pass: find all dependencies of protected units // We need to prevent destruction of any unit that a protected unit depends on dependencyPaths := make(map[string]bool) for _, unit := range units { if protectedUnits[unit.Path()] { collectDependencies(unit, dependencyPaths) } } // Third pass: mark dependencies as excluded for _, unit := range units { if dependencyPaths[unit.Path()] && !protectedUnits[unit.Path()] { unit.SetExcluded(true) l.Debugf("Unit %s is excluded because it's a dependency of a protected unit", unit.Path()) } } } // maxDependencyTraversalDepth bounds the depth of dependency traversal to prevent excessive recursion. const maxDependencyTraversalDepth = 256 // applyFilterAllowDestroyExclusions excludes units with destroy runs from Git-based filters // when the --filter-allow-destroy flag is not set. This prevents accidental destruction // of infrastructure when using Git-based filters. func applyFilterAllowDestroyExclusions(l log.Logger, opts *options.TerragruntOptions, units []*component.Unit) { if opts.FilterAllowDestroy { return } for _, unit := range units { discoveryCtx := unit.DiscoveryContext() if discoveryCtx == nil { continue } if discoveryCtx.Ref != "" && iacargs.New(discoveryCtx.Args...).IsDestroyCommand(discoveryCtx.Cmd) { unit.SetExcluded(true) l.Warnf("The `%s` unit was removed in the `%s` Git reference, but the `--filter-allow-destroy` flag was not used. The unit will be excluded during applies unless --filter-allow-destroy is used.", unit.DisplayPath(), discoveryCtx.Ref) } } } // collectDependencies collects dependency paths for a unit with a bounded recursion depth. func collectDependencies(unit *component.Unit, paths map[string]bool) { collectDependenciesBounded(unit, paths, 0) } // collectDependenciesBounded recursively collects all dependency paths for a unit up to maxDependencyTraversalDepth. func collectDependenciesBounded(unit *component.Unit, paths map[string]bool, depth int) { if depth >= maxDependencyTraversalDepth { return } for _, dep := range unit.Dependencies() { depUnit, ok := dep.(*component.Unit) if !ok { continue } if !paths[depUnit.Path()] { paths[depUnit.Path()] = true collectDependenciesBounded(depUnit, paths, depth+1) } } } ================================================ FILE: internal/runner/runnerpool/runner_test.go ================================================ package runnerpool_test import ( "context" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" thlogger "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func TestDiscoveryResolverMatchesLegacyPaths(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create a trivial tf file so the resolver doesn't skip the unit require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "main.tf"), []byte(""), 0o600)) tgPath := filepath.Join(tmpDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(tgPath, []byte(""), 0o600)) // Discovery produces a component with or without config; using empty config is fine here discUnit := component.NewUnit(tmpDir).WithConfig(&config.TerragruntConfig{}) discovered := component.Components{discUnit} // Build runner stack from discovery and verify units opts, err := options.NewTerragruntOptionsForTest(tgPath) require.NoError(t, err) l := thlogger.CreateLogger() runner, err := runnerpool.NewRunnerPoolStack(context.Background(), l, opts, discovered) require.NoError(t, err) units := runner.GetStack().Units require.Len(t, units, 1) require.Equal(t, tmpDir, units[0].Path()) } ================================================ FILE: internal/runner/runnerpool/writer.go ================================================ package runnerpool import ( "bytes" "io" "sync" ) // UnitWriter buffers output for a single unit and flushes incrementally during execution. // This prevents interleaved output when multiple units run in parallel while ensuring // output appears in real-time during execution, not just at completion. type UnitWriter struct { out io.Writer buffer bytes.Buffer mu sync.Mutex } // NewUnitWriter returns a new UnitWriter instance. func NewUnitWriter(out io.Writer) *UnitWriter { return &UnitWriter{ out: out, } } func (writer *UnitWriter) Write(p []byte) (int, error) { writer.mu.Lock() defer writer.mu.Unlock() n, err := writer.buffer.Write(p) if err != nil { return n, err } if flushErr := writer.flushCompleteLines(); flushErr != nil { return n, flushErr } return n, err } // flushCompleteLines flushes any complete lines (ending with newline) from the buffer. // Partial lines (without trailing newline) remain in the buffer. func (writer *UnitWriter) flushCompleteLines() error { if writer.out == nil { return nil } buf := writer.buffer.Bytes() lastNewline := bytes.LastIndexByte(buf, '\n') if lastNewline >= 0 { lineCount := lastNewline + 1 lines := writer.buffer.Next(lineCount) if _, err := writer.out.Write(lines); err != nil { writer.buffer.Write(lines) return err } } return nil } // Flush flushes all buffered data to the output writer. func (writer *UnitWriter) Flush() error { writer.mu.Lock() defer writer.mu.Unlock() if writer.out != nil { if _, err := writer.buffer.WriteTo(writer.out); err != nil { return err } } return nil } // Unwrap returns the underlying output writer that this UnitWriter wraps. func (writer *UnitWriter) Unwrap() io.Writer { return writer.out } ================================================ FILE: internal/runner/runnerpool/writer_test.go ================================================ package runnerpool_test import ( "errors" "strings" "testing" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" ) func TestUnitWriter_WriteErrorPropagation(t *testing.T) { t.Parallel() writeErr := errors.New("write failed") failingWriter := &failingWriter{err: writeErr} writer := runnerpool.NewUnitWriter(failingWriter) data := []byte("line 1\nline 2\n") n, err := writer.Write(data) require.Error(t, err) require.Equal(t, writeErr, err) require.Equal(t, len(data), n) err = writer.Flush() require.Error(t, err) require.Equal(t, writeErr, err) } func TestUnitWriter_FlushCompleteLines(t *testing.T) { t.Parallel() var buf strings.Builder writer := runnerpool.NewUnitWriter(&buf) data := []byte("line 1\nline 2\npartial") _, err := writer.Write(data) require.NoError(t, err) output := buf.String() require.Contains(t, output, "line 1") require.Contains(t, output, "line 2") require.NotContains(t, output, "partial") err = writer.Flush() require.NoError(t, err) require.Contains(t, buf.String(), "partial") } type failingWriter struct { err error } func (w *failingWriter) Write(_ []byte) (int, error) { return 0, w.err } ================================================ FILE: internal/services/catalog/catalog.go ================================================ // Package catalog provides the core functionality for the Terragrunt catalog command. // It handles the logic for fetching and processing module information from remote repositories. // // This logic is intentionally isolated from the CLI package, as that package is focused on // spinning up the Terminal User Interface (TUI), and forwarding user input to the catalog service. // // This should result in an implementation that is easier to test, and more maintainable. package catalog import ( "context" "fmt" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // NewRepoFunc defines the signature for a function that creates a new repository. // This allows for mocking in tests. type NewRepoFunc func(ctx context.Context, l log.Logger, cloneURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) const ( // tempDirFormat is used to create unique temporary directory names for catalog repositories. // It uses a hexadecimal representation of a SHA1 hash of the repo URL. tempDirFormat = "catalog-%s" // Changed from catalog%x to catalog-%s for clarity with Sprintf. ) // CatalogService defines the interface for the catalog service. // It's responsible for fetching and processing module information. type CatalogService interface { // Load retrieves all modules from the configured repositories. // It stores discovered modules internally. Load(ctx context.Context, l log.Logger) error // Modules returns the discovered modules. Modules() module.Modules // Scaffold scaffolds a module. Scaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, module *module.Module) error // WithNewRepoFunc allows overriding the default function used to create repository instances. // This is primarily useful for testing. WithNewRepoFunc(fn NewRepoFunc) CatalogService // WithRepoURL allows overriding the repository URL. // This is primarily useful for testing. WithRepoURL(repoURL string) CatalogService } // catalogServiceImpl is the concrete implementation of CatalogService. // It holds the necessary options and configuration to perform its tasks. type catalogServiceImpl struct { opts *options.TerragruntOptions newRepo NewRepoFunc repoURL string modules module.Modules } // NewCatalogService creates a new instance of catalogServiceImpl with default settings. // It requires TerragruntOptions and an optional initial repository URL. // Configuration methods like WithNewRepoFunc can be chained to customize the service. func NewCatalogService(opts *options.TerragruntOptions) *catalogServiceImpl { return &catalogServiceImpl{ opts: opts, newRepo: module.NewRepo, } } // WithNewRepoFunc allows overriding the default function used to create repository instances. // This is primarily useful for testing. func (s *catalogServiceImpl) WithNewRepoFunc(fn NewRepoFunc) CatalogService { s.newRepo = fn return s } // WithRepoURL allows overriding the repository URL. // This is primarily useful for testing. func (s *catalogServiceImpl) WithRepoURL(repoURL string) CatalogService { s.repoURL = repoURL return s } // Load implements the CatalogService interface. // It contains the core logic for cloning/updating repositories and finding Terragrunt modules within them. func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error { repoURLs := []string{s.repoURL} // If no specific repoURL was provided to the service, try to read from catalog config. if s.repoURL == "" { _, pctx := configbridge.NewParsingContext(ctx, l, s.opts) catalogCfg, err := config.ReadCatalogConfig(ctx, l, pctx) if err != nil { return errors.Errorf("failed to read catalog configuration: %w", err) } if catalogCfg != nil && len(catalogCfg.URLs) > 0 { repoURLs = catalogCfg.URLs } else { return errors.Errorf("no catalog URLs provided") } } // Remove duplicates repoURLs = util.RemoveDuplicates(repoURLs) if len(repoURLs) == 0 || (len(repoURLs) == 1 && repoURLs[0] == "") { return errors.Errorf("no valid repository URLs specified after configuration and flag processing") } var allModules module.Modules // Evaluate experimental features for symlinks and content-addressable storage. walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks) allowCAS := s.opts.Experiments.Evaluate(experiment.CAS) var errs []error for _, currentRepoURL := range repoURLs { if currentRepoURL == "" { l.Warnf("Empty repository URL encountered, skipping.") continue } // Create a unique path in the system's temporary directory for this repository. // The path is based on a SHA1 hash of the repository URL to ensure uniqueness and idempotency. encodedRepoURL := util.EncodeBase64Sha1(currentRepoURL) tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL)) l.Debugf("Processing repository %s in temporary path %s", currentRepoURL, tempPath) // Initialize the repository. This might involve cloning or updating. // Use the newRepo function stored in the service instance. repo, err := s.newRepo(ctx, l, currentRepoURL, tempPath, walkWithSymlinks, allowCAS, s.opts.RootWorkingDir) if err != nil { l.Errorf("Failed to initialize repository %s: %v", currentRepoURL, err) errs = append(errs, err) continue } // Find modules within the initialized repository. repoModules, err := repo.FindModules(ctx) if err != nil { l.Errorf("Failed to find modules in repository %s: %v", currentRepoURL, err) errs = append(errs, err) continue } l.Infof("Found %d module(s) in repository %q", len(repoModules), currentRepoURL) allModules = append(allModules, repoModules...) } s.modules = allModules if len(errs) > 0 { return errors.Errorf("failed to find modules in some repositories: %v", errs) } if len(allModules) == 0 { return errors.Errorf("no modules found in any of the configured repositories") } return nil } func (s *catalogServiceImpl) Modules() module.Modules { return s.modules } func (s *catalogServiceImpl) Scaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, module *module.Module) error { l.Infof("Scaffolding module: %q", module.TerraformSourcePath()) return scaffold.Run(ctx, l, opts, module.TerraformSourcePath(), "") } ================================================ FILE: internal/services/catalog/catalog_test.go ================================================ package catalog_test import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/services/catalog" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestListModules_HappyPath(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { // Use a temp dir for the dummyRepoDir to ensure cleanup and parallelism safety. dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", "")) os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) if repoURL == "github.com/gruntwork-io/repo1" { readme1Path := filepath.Join(dummyRepoDir, "README.md") os.WriteFile(readme1Path, []byte("# module1-title\nThis is module1."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "module1.tf"), []byte{}, 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } if repoURL == "github.com/gruntwork-io/repo2" { readme2Path := filepath.Join(dummyRepoDir, "README.md") os.WriteFile(readme2Path, []byte("# module2-title\nThis is module2."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "module2.tf"), []byte{}, 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } return nil, fmt.Errorf("unexpected repoURL in mock newRepoFunc: %s", repoURL) } tmpDir := helpers.TmpDirWOSymlinks(t) rootFile := filepath.Join(tmpDir, "root.hcl") err := os.WriteFile(rootFile, []byte(`catalog { urls = [ "github.com/gruntwork-io/repo1", "github.com/gruntwork-io/repo2", ] }`), 0600) require.NoError(t, err) unitDir := filepath.Join(tmpDir, "unit") os.MkdirAll(unitDir, 0755) opts.TerragruntConfigPath = filepath.Join(unitDir, "terragrunt.hcl") svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo) l := logger.CreateLogger() err = svc.Load(t.Context(), l) require.NoError(t, err) modules := svc.Modules() require.NotNil(t, modules) assert.Len(t, modules, 2) assert.Equal(t, "module1-title", modules[0].Title()) assert.Equal(t, "module2-title", modules[1].Title()) } func TestListModules_NoRepositoriesConfigured(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName tmpDir := helpers.TmpDirWOSymlinks(t) opts.TerragruntConfigPath = filepath.Join(tmpDir, "nonexistent-terragrunt.hcl") // No customNewRepoFunc needed as it should error before trying to create a repo. svc := catalog.NewCatalogService(opts) l := logger.CreateLogger() err := svc.Load(t.Context(), l) require.Error(t, err) assert.Contains(t, err.Error(), "no catalog URLs provided") } func TestListModules_SingleRepoFromFlag(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { if repoURL == "github.com/gruntwork-io/only-repo" { dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), "only-repo") os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "README.md"), []byte("# moduleA-title"), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "moduleA.tf"), []byte{}, 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } return nil, fmt.Errorf("unexpected repoURL: %s", repoURL) } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/only-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) modules := svc.Modules() require.NoError(t, err) require.NotNil(t, modules) assert.Len(t, modules, 1) assert.Equal(t, "moduleA-title", modules[0].Title()) } func TestListModules_ErrorFromNewRepo(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName expectedErr := errors.Errorf("failed to clone repo") mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { return nil, expectedErr } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/error-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) require.Error(t, err) assert.Contains(t, err.Error(), "failed to find modules in some repositories", "Error message mismatch: %v", err) assert.True(t, errors.Is(err, expectedErr) || strings.Contains(err.Error(), expectedErr.Error()), "Original error not wrapped correctly: %v", err) } func TestListModules_ErrorFromFindModules(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { if repoURL == "github.com/gruntwork-io/find-error-repo" { dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), "find-error-repo-dir") os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) moduleDirWithBadReadme := filepath.Join(dummyRepoDir, "problem_module") os.MkdirAll(moduleDirWithBadReadme, 0755) os.WriteFile(filepath.Join(moduleDirWithBadReadme, "main.tf"), []byte("{}"), 0644) os.Mkdir(filepath.Join(moduleDirWithBadReadme, "README.md"), 0755) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } return nil, fmt.Errorf("unexpected repoURL: %s", repoURL) } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/find-error-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) require.Error(t, err) assert.Contains(t, err.Error(), "no modules found in any of the configured repositories") } func TestListModules_TofuExtension(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { if repoURL == "github.com/gruntwork-io/tofu-repo" { dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), "tofu-repo") os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "README.md"), []byte("# tofu-module\nOpenTofu module using .tofu extensions."), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "main.tofu"), []byte("resource \"null_resource\" \"test\" {}"), 0644) os.WriteFile(filepath.Join(dummyRepoDir, "variables.tofu"), []byte("variable \"name\" {}"), 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } return nil, fmt.Errorf("unexpected repoURL: %s", repoURL) } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/tofu-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) modules := svc.Modules() require.NoError(t, err) require.NotNil(t, modules) assert.Len(t, modules, 1) assert.Equal(t, "tofu-module", modules[0].Title()) } func TestListModules_MixedTfAndTofu(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { if repoURL == "github.com/gruntwork-io/mixed-repo" { dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), "mixed-repo") os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) // Module with .tf files tfModDir := filepath.Join(dummyRepoDir, "modules", "tf-module") os.MkdirAll(tfModDir, 0755) os.WriteFile(filepath.Join(tfModDir, "README.md"), []byte("# tf-module\nTerraform module."), 0644) os.WriteFile(filepath.Join(tfModDir, "main.tf"), []byte("resource \"null_resource\" \"test\" {}"), 0644) // Module with .tofu files tofuModDir := filepath.Join(dummyRepoDir, "modules", "tofu-module") os.MkdirAll(tofuModDir, 0755) os.WriteFile(filepath.Join(tofuModDir, "README.md"), []byte("# tofu-module\nOpenTofu module."), 0644) os.WriteFile(filepath.Join(tofuModDir, "main.tofu"), []byte("resource \"null_resource\" \"test\" {}"), 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } return nil, fmt.Errorf("unexpected repoURL: %s", repoURL) } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/mixed-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) modules := svc.Modules() require.NoError(t, err) require.NotNil(t, modules) require.Len(t, modules, 2) titles := []string{modules[0].Title(), modules[1].Title()} assert.Contains(t, titles, "tf-module") assert.Contains(t, titles, "tofu-module") } func TestListModules_NoModulesFound(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptions() opts.ScaffoldRootFileName = config.RecommendedParentConfigName mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, rootWorkingDir string) (*module.Repo, error) { dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), "empty-repo-dir") os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "config"), []byte("[remote \"origin\"]\nurl = "+repoURL), 0644) os.WriteFile(filepath.Join(dummyRepoDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644) return module.NewRepo(ctx, logger, dummyRepoDir, path, walkWithSymlinks, allowCAS, "") } svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo).WithRepoURL("github.com/gruntwork-io/empty-repo") l := logger.CreateLogger() err := svc.Load(t.Context(), l) require.Error(t, err) modules := svc.Modules() assert.Contains(t, err.Error(), "no modules found in any of the configured repositories") assert.Empty(t, modules, "Should return empty modules slice on 'no modules found' error") } ================================================ FILE: internal/services/catalog/module/doc.go ================================================ package module import ( "os" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" ) const ( mdExt = ".md" adocExt = ".adoc" docTitle docDataKey = iota docDescription docContent tagH1Block docTagName = iota tagH2Block ) var ( // `strings.EqualFold` is used (case insensitive) while comparing docFiles = []string{"README.md", "README.adoc"} frontmatterKeys = map[string]docDataKey{ "name": docTitle, "description": docDescription, } ) type docDataKey byte type docTagName byte type DocRegs []*regexp.Regexp func (regs DocRegs) Replace(str string) string { for _, reg := range regs { str = reg.ReplaceAllString(str, "$1") } return str } type Doc struct { tagCache map[docDataKey]string tagRegs map[docTagName]*regexp.Regexp frontmatterCache map[docDataKey]string frontmatterReg *regexp.Regexp rawContent string fileExt string tagStripRegs DocRegs } func NewDoc(rawContent, fileExt string) *Doc { doc := &Doc{ rawContent: rawContent, fileExt: fileExt, tagRegs: make(map[docTagName]*regexp.Regexp), frontmatterReg: regexp.MustCompile(`(?i)^[\s\n]*`), } switch fileExt { case mdExt: doc.tagRegs[tagH1Block] = regexp.MustCompile(`(?:^|\n)\#{1}\s([\S\s]+?)(?:[\r\n]+\#|[\r\n]*$)`) doc.tagRegs[tagH2Block] = regexp.MustCompile(`(?:^|\n)\#{2}\s([\S\s]+?)(?:[\r\n]+\#|[\r\n]*$)`) doc.tagStripRegs = DocRegs{ // code regexp.MustCompile("`{3}" + `.*[\r\n]+`), regexp.MustCompile("`(.+?)`"), // html regexp.MustCompile("<(.*?)>"), // bold regexp.MustCompile(`\*\*([^*]+)\*\*`), regexp.MustCompile(`__([^_]+)__`), // italic regexp.MustCompile(`\*([^*]+)\*`), regexp.MustCompile(`_([^_]+)_`), // setext header regexp.MustCompile(`^[=\-]{2,}\s*$`), // foot note regexp.MustCompile(`\[\^.+?\](\: .*?$)?`), regexp.MustCompile(`\s{0,2}\[.*?\]: .*?$`), // image regexp.MustCompile(`\!\[(?:.*?)\]\s?[\[\(].*?[\]\)]`), // link regexp.MustCompile(`\[([\S\s]*?)\][\[\(].*?[\]\)]`), // blockquote regexp.MustCompile(`>\s*`), // ref link regexp.MustCompile(`^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$`), // header regexp.MustCompile(`(?m)^\#{1,6}\s*([^#]+)\s*(\#{1,6})?$`), // horizontal rule regexp.MustCompile(`^[-\*_]{3,}\s*$`), } case adocExt: doc.tagRegs[tagH1Block] = regexp.MustCompile(`(?:^|\n)\={1}\s([\S\s]+?)(?:[\r\n]+\=|[\r\n]*$)`) doc.tagRegs[tagH2Block] = regexp.MustCompile(`(?:^|\n)\={2}\s([\S\s]+?)(?:[\r\n]+\=|[\r\n]*$)`) doc.tagStripRegs = DocRegs{ // html regexp.MustCompile("<(.*?)>"), // ifdef endif regexp.MustCompile(`ifdef::env-github\[\][\S\s]*?endif::\[\]`), // comment regexp.MustCompile(`(?m)^/{2}.*$`), // ex. :name:value regexp.MustCompile(`(?m)^:[-!\w]+:.*$`), // ex. toc::[] regexp.MustCompile(`\w+::\[.*?\]`), // bold regexp.MustCompile(`\*\*([^\s][^*]+[^\s])\*\*`), regexp.MustCompile(`\*([^\s][^*]+[^\s])\*`), // italic regexp.MustCompile(`_{1,2}([^\s][^_]+[^\s])_{1,2}`), // image regexp.MustCompile(`image:[^\]]+]`), // link regexp.MustCompile(`(?:link|https):[\S\s]+?\[([\S\s]+?)\]`), // header regexp.MustCompile(`(?m)^\={1,6}\s*([^=]+)\s*(\={1,6})?$`), // multiple line break regexp.MustCompile(`((?:\r\n?|\n){2})(?:\r\n?|\n)*`), } } return doc } func FindDoc(dir string) (*Doc, error) { var filePath, fileExt string files, err := os.ReadDir(dir) if err != nil { return nil, errors.New(err) } for _, file := range files { if file.IsDir() { continue } for _, readmeFile := range docFiles { if strings.EqualFold(readmeFile, file.Name()) { filePath = filepath.Join(dir, file.Name()) fileExt = filepath.Ext(filePath) break } } // `md` files have priority over `adoc` files if strings.EqualFold(fileExt, mdExt) { break } } if filePath == "" { return &Doc{}, nil } contentByte, err := os.ReadFile(filePath) if err != nil { return nil, errors.New(err) } rawContent := string(contentByte) return NewDoc(rawContent, fileExt), nil } func (doc *Doc) Title() string { if title := doc.parseFrontmatter(docTitle); title != "" { return title } return doc.parseTag(docTitle) } func (doc *Doc) Description(maxLenght int) string { desc := doc.parseFrontmatter(docDescription) if desc == "" { desc = doc.parseTag(docDescription) } if maxLenght == 0 { return desc } var ( sentences = strings.Split(desc, ".") symbols int ) for i, sentence := range sentences { symbols += len(sentence) if symbols > maxLenght { if i == 0 { desc = sentence } else { desc = strings.Join(sentences[:i], ".") } desc += "." break } } return desc } func (doc *Doc) Content(stripTags bool) string { if !stripTags { return doc.rawContent } return doc.parseTag(docContent) } func (doc *Doc) IsMarkDown() bool { return doc.fileExt == mdExt } // parseFrontmatter parses Markdown files with frontmatter, which we use as the preferred title/description source. func (doc *Doc) parseFrontmatter(key docDataKey) string { if doc.frontmatterReg == nil { return "" } if doc.frontmatterCache == nil { doc.frontmatterCache = make(map[docDataKey]string) match := doc.frontmatterReg.FindStringSubmatch(doc.rawContent) if len(match) == 0 { return "" } lines := strings.SplitSeq(match[1], "\n") for line := range lines { if parts := strings.Split(line, ":"); len(parts) > 1 { key := strings.ToLower(strings.TrimSpace(parts[0])) val := strings.TrimSpace(parts[1]) if key, ok := frontmatterKeys[key]; ok { doc.frontmatterCache[key] = val } } } } return doc.frontmatterCache[key] } // parseTag parses Markdown/AsciiDoc files, stips tags and extracts the H1 header as the title and the H1+H2 bodies as the description. func (doc *Doc) parseTag(key docDataKey) string { if doc.tagRegs == nil { return "" } if doc.tagCache == nil { doc.tagCache = make(map[docDataKey]string) var h1Body, h2Body string for tagName, tagReg := range doc.tagRegs { match := tagReg.FindStringSubmatch(doc.rawContent) if len(match) == 0 { continue } lines := strings.Split(match[1], "\n") switch tagName { case tagH1Block: // header title doc.tagCache[docTitle] = lines[0] if len(lines) > 1 { h1Body = strings.Join(lines[1:], "\n") } case tagH2Block: if len(lines) > 1 { h2Body = strings.Join(lines[1:], "\n") } } } desc := h1Body + " " + h2Body // strip doc tags desc = doc.tagStripRegs.Replace(desc) // remove redundant spaces and new lines desc = strings.Join(strings.Fields(desc), " ") doc.tagCache[docDescription] = desc // strip doc tags content := doc.tagStripRegs.Replace(doc.rawContent) doc.tagCache[docContent] = content } return doc.tagCache[key] } ================================================ FILE: internal/services/catalog/module/doc_test.go ================================================ package module_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/stretchr/testify/assert" ) var testFrontmatterEcsCluster = ` # Amazon ECS Cluster [![Maintained by Gruntwork](https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg)](https://gruntwork.io) ` var testFrontmatterAsgService = ` # Auto Scaling Group [![Maintained by Gruntwork](https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg)](https://gruntwork.io) ` func TestFrontmatter(t *testing.T) { t.Parallel() testCases := []struct { content string expectedName string expectedDesc string }{ { testFrontmatterEcsCluster, "Amazon ECS Cluster", "Deploy an Amazon ECS Cluster.", }, { testFrontmatterAsgService, "Auto Scaling Group (ASG)", "Deploy an AMI across an Auto Scaling Group (ASG), with support for zero-downtime, rolling deployment, load balancing, health checks, service discovery, and auto scaling.", }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() doc := module.NewDoc(tc.content, "") assert.Equal(t, tc.expectedName, doc.Title(), "Frontmatter Name") assert.Equal(t, tc.expectedDesc, doc.Description(0), "Frontmatter Description") }) } } var testH1EksK8sArgocd = ` # EKS K8s GitOps Module This module deploys [Argo CD](https://argo-cd.readthedocs.io/en/stable/) to an EKS cluster. Argo CD is a declarative GitOps continuous delivery tool for Kubernetes. See the [Argo CD](https://argo-cd.readthedocs.io/en/stable/) for more details. This module supports deploying the Argo CD resources to Fargate in addition to EC2 Worker Nodes. # Gruntwork GitOps "GruntOps" GitOps is an operational framework that is built around DevOps best practices for a standardized approach to managing the lifecycle of Kubernetes based deployments. GitOps provides a unified approach to the deployment and management of container workloads, with Git being the single source of truth for the state of the container infrastructure. GitOps is a very developer-centric workflow that works best when adopted by individuals and teams that follow a git based development lifecycle. The core principles of GitOps have been at the center of Gruntwork from the beginning! ## Getting Started To use this module, you will need to have a running EKS cluster prior to deploying this module. See the [Argo CD Example](/examples/eks-cluster-with-argocd/) for an example of how to deploy this module. ` var testH1EksCloudwatchAgent = ` # EKS CloudWatch Agent Module This Terraform Module installs and configures [Amazon CloudWatch Agent](https://github.com/aws/amazon-cloudwatch-agent/) on an EKS cluster, so that each node runs the agent to collect more system-level metrics from Amazon EC2 instances and ship them to Amazon CloudWatch. This extra metric data allows using [CloudWatch Container Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ContainerInsights.html) for a single pane of glass for application, performance, host, control plane, data plane insights. This module uses the [community helm chart](https://github.com/aws/eks-charts/tree/8b063ec/stable/aws-cloudwatch-metrics), with a set of best practices inputs. **This module is for setting up CloudWatch Agent for EKS clusters with worker nodes (self-managed or managed node groups) that have support for [` + "`DaemonSets`" + `](https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/). CloudWatch Container Insights is [not supported for EKS Fargate](https://github.com/aws/containers-roadmap/issues/920).** ## How does this work? CloudWatch automatically collects metrics for many resources, such as CPU, memory, disk, and network. Container Insights also provides diagnostic information, such as container restart failures, to help you isolate issues and resolve them quickly. ` var testH1EcsCluster = ` # Amazon ECS Cluster [![Maintained by Gruntwork](https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg)](https://gruntwork.io) ![Terraform version](https://img.shields.io/badge/tf-%3E%3D1.1.0-blue.svg) [![Docs](https://img.shields.io/badge/docs-docs.gruntwork.io-informational)](https://docs.gruntwork.io/reference/services/app-orchestration/amazon-ecs-cluster) ## Overview This service contains [Terraform](https://www.terraform.io) code to deploy a production-grade ECS cluster on [AWS](https://aws.amazon.com) using [Elastic Container Service (ECS)](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html). This service launches an ECS cluster on top of an Auto Scaling Group that you manage. If you wish to launch an ECS cluster on top of Fargate that is completely managed by AWS, refer to the [ecs-fargate-cluster module](../ecs-fargate-cluster). Refer to the section [EC2 vs Fargate Launch Types](https://github.com/gruntwork-io/terraform-aws-ecs/blob/master/core-concepts.md#ec2-vs-fargate-launch-types) for more information on the differences between the two flavors. ` var testH1EksAWSAuthMerger = ` :type: service :name: EKS AWS Auth Merger :description: Manage the aws-auth ConfigMap across multiple independent ConfigMaps. // AsciiDoc TOC settings :toc: :toc-placement!: :toc-title: // GitHub specific settings. See https://gist.github.com/dcode/0cfbf2699a1fe9b46ff04c41721dda74 for details. ifdef::env-github[] :tip-caption: :bulb: :note-caption: :information_source: endif::[] = EKS AWS Auth Merger image:https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg[link="https://gruntwork.io/?ref=repo_aws_eks"] image:https://img.shields.io/badge/tf-%3E%3D1.1.0-blue[Terraform version] image:https://img.shields.io/badge/k8s-1.24%20~%201.28-5dbcd2[K8s version] This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes. The official way to manage the mapping is to add values in a single, central ` + "`ConfigMap`" + `. This module allows you to break up the central ` + "`ConfigMap`" + ` across multiple, separate ` + "`ConfigMaps`" + ` each configuring a subset of the mappings you ultimately want to use, allowing you to update entries in the ` + "`ConfigMap`" + ` in isolated modules (e.g., when you add a new IAM role in a separate module from the EKS cluster). The ` + "`aws-auth-merger`" + ` watches for ` + "`aws-auth`" + ` compatible ` + "`ConfigMaps`" + ` that can be merged to manage the ` + "`aws-auth`" + ` authentication ` + "`ConfigMap`" + ` for EKS. toc::[] == Features * Break up the ` + "`aws-auth`" + ` Kubernetes ` + "`ConfigMap`" + ` across multiple objects. * Automatically merge new ` + "`ConfigMaps`" + ` as they are added and removed. * Track automatically generated ` + "`aws-auth`" + ` source ` + "`ConfigMaps`" + ` that are generated by EKS. ` func TestElement(t *testing.T) { t.Parallel() testCases := []struct { content string fileExt string expectedTitle string expectedDescription string maxDescriptionLength int }{ { content: testH1EksK8sArgocd, fileExt: ".md", maxDescriptionLength: 200, expectedTitle: "EKS K8s GitOps Module", expectedDescription: "This module deploys Argo CD to an EKS cluster. Argo CD is a declarative GitOps continuous delivery tool for Kubernetes. See the Argo CD for more details.", }, { content: testH1EksCloudwatchAgent, fileExt: ".md", maxDescriptionLength: 200, expectedTitle: "EKS CloudWatch Agent Module", expectedDescription: "This Terraform Module installs and configures Amazon CloudWatch Agent on an EKS cluster, so that each node runs the agent to collect more system-level metrics from Amazon EC2 instances and ship them to Amazon CloudWatch.", }, { content: testH1EcsCluster, fileExt: ".md", maxDescriptionLength: 200, expectedTitle: "Amazon ECS Cluster", expectedDescription: "This service contains Terraform code to deploy a production-grade ECS cluster on AWS using Elastic Container Service (ECS).", }, { content: testH1EksAWSAuthMerger, fileExt: ".adoc", maxDescriptionLength: 200, expectedTitle: "EKS AWS Auth Merger", expectedDescription: "This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes.", }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() doc := module.NewDoc(tc.content, tc.fileExt) assert.Equal(t, tc.expectedTitle, doc.Title(), "Title") assert.Equal(t, tc.expectedDescription, doc.Description(tc.maxDescriptionLength), "Description") }) } } ================================================ FILE: internal/services/catalog/module/module.go ================================================ // Package module provides a struct to represent an OpenTofu/Terraform module. package module import ( "os" "path/filepath" "strings" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( defaultDescription = "(no description found)" maxDescriptionLength = 200 ) var ignoreFiles = []string{"terraform-cloud-enterprise-private-module-registry-placeholder.tf"} type Modules []*Module type Module struct { *Repo *Doc cloneURL string repoPath string moduleDir string url string } // NewModule returns a module instance if the given `moduleDir` path contains an OpenTofu/Terraform module, otherwise returns nil. func NewModule(repo *Repo, moduleDir string) (*Module, error) { module := &Module{ Repo: repo, cloneURL: repo.cloneURL, repoPath: repo.path, moduleDir: moduleDir, } if ok, err := module.isValid(); !ok || err != nil { return nil, err } repo.logger.Debugf("Found module in directory %q", moduleDir) module.url = repo.ModuleURL(moduleDir) repo.logger.Debugf("Module URL: %s", module.url) modulePath := filepath.Join(module.repoPath, module.moduleDir) doc, err := FindDoc(modulePath) if err != nil { return nil, err } module.Doc = doc return module, nil } func (module *Module) Logger() log.Logger { return module.logger } // FilterValue implements /github.com/charmbracelet/bubbles.list.Item.FilterValue func (module *Module) FilterValue() string { return module.Title() } // Title implements /github.com/charmbracelet/bubbles.list.DefaultItem.Title func (module *Module) Title() string { if title := module.Doc.Title(); title != "" { return strings.TrimSpace(title) } return filepath.Base(module.moduleDir) } // Description implements /github.com/charmbracelet/bubbles.list.DefaultItem.Description func (module *Module) Description() string { if desc := module.Doc.Description(maxDescriptionLength); desc != "" { return desc } return defaultDescription } func (module *Module) URL() string { return module.url } // TerraformSourcePath returns the module source URL in the format expected by go-getter: // baseURL//moduleDir?query (e.g., git::https://github.com/org/repo.git//modules/foo?ref=v1.0.0) func (module *Module) TerraformSourcePath() string { if module.moduleDir == "" { return module.cloneURL } // Split on ? to separate base URL from query string base, query, _ := strings.Cut(module.cloneURL, "?") result := base + "//" + module.moduleDir if query != "" { result += "?" + query } return result } func (module *Module) isValid() (bool, error) { files, err := os.ReadDir(filepath.Join(module.repoPath, module.moduleDir)) if err != nil { return false, errors.New(err) } for _, file := range files { if file.IsDir() { continue } if collections.ListContainsElement(ignoreFiles, file.Name()) { continue } if util.IsTFFile(file.Name()) { return true, nil } } return false, nil } func (module *Module) ModuleDir() string { return module.moduleDir } // NewModuleForTest creates a Module for testing purposes. func NewModuleForTest(cloneURL, moduleDir string) *Module { return &Module{ cloneURL: cloneURL, moduleDir: moduleDir, } } ================================================ FILE: internal/services/catalog/module/module_test.go ================================================ package module_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/stretchr/testify/assert" ) func TestTerraformSourcePath(t *testing.T) { t.Parallel() testCases := []struct { name string cloneURL string moduleDir string expected string }{ { name: "root module without ref", cloneURL: "git::https://github.com/org/repo.git", moduleDir: "", expected: "git::https://github.com/org/repo.git", }, { name: "root module with ref", cloneURL: "git::https://github.com/org/repo.git?ref=v1.0.0", moduleDir: "", expected: "git::https://github.com/org/repo.git?ref=v1.0.0", }, { name: "submodule without ref", cloneURL: "git::https://github.com/org/repo.git", moduleDir: "modules/foo", expected: "git::https://github.com/org/repo.git//modules/foo", }, { name: "submodule with ref", cloneURL: "git::https://github.com/org/repo.git?ref=v1.0.0", moduleDir: "modules/foo", expected: "git::https://github.com/org/repo.git//modules/foo?ref=v1.0.0", }, { name: "ssh url with ref", cloneURL: "git::ssh://git@github.com/org/repo.git?ref=v1.0.0", moduleDir: "modules/bar", expected: "git::ssh://git@github.com/org/repo.git//modules/bar?ref=v1.0.0", }, { name: "multiple query params", cloneURL: "git::https://github.com/org/repo.git?ref=v1.0.0&depth=1", moduleDir: "modules/foo", expected: "git::https://github.com/org/repo.git//modules/foo?ref=v1.0.0&depth=1", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() m := module.NewModuleForTest(tc.cloneURL, tc.moduleDir) assert.Equal(t, tc.expected, m.TerraformSourcePath()) }) } } ================================================ FILE: internal/services/catalog/module/repo.go ================================================ package module import ( "context" "fmt" "io/fs" "os" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gitsight/go-vcsurl" "github.com/gruntwork-io/go-commons/files" "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-getter/v2" "gopkg.in/ini.v1" ) const ( githubHost = "github.com" githubEnterpriseRegex = `^(github\.(.+))$` gitlabHost = "gitlab.com" azuredevHost = "dev.azure.com" bitbucketHost = "bitbucket.org" gitlabSelfHostedRegex = `^(gitlab\.(.+))$` cloneCompleteSentinel = ".catalog-clone-complete" ) var ( gitHeadBranchNameReg = regexp.MustCompile(`^.*?([^/]+)$`) repoNameFromCloneURLReg = regexp.MustCompile(`(?i)^.*?([-a-z0-9_.]+)[^/]*?(?:\.git)?$`) modulesPaths = []string{"modules"} includedGitFiles = []string{"HEAD", "config"} ) type Repo struct { logger log.Logger cloneURL string path string rootWorkingDir string RemoteURL string BranchName string walkWithSymlinks bool allowCAS bool } func NewRepo(ctx context.Context, l log.Logger, cloneURL, path string, walkWithSymlinks bool, allowCAS bool, rootWorkingDir string) (*Repo, error) { repo := &Repo{ logger: l, cloneURL: cloneURL, path: path, walkWithSymlinks: walkWithSymlinks, allowCAS: allowCAS, rootWorkingDir: rootWorkingDir, } if err := repo.clone(ctx, l); err != nil { return nil, err } if err := repo.parseRemoteURL(); err != nil { return nil, err } if err := repo.parseBranchName(); err != nil { return nil, err } return repo, nil } // FindModules clones the repository if `repoPath` is a URL, searches for Terragrunt modules, indexes their README.* files, and returns module instances. func (repo *Repo) FindModules(ctx context.Context) (Modules, error) { var modules Modules // check if root repo path is a module dir if module, err := NewModule(repo, ""); err != nil { return nil, err } else if module != nil { modules = append(modules, module) } for _, modulesPath := range modulesPaths { modulesPath = filepath.Join(repo.path, modulesPath) if !files.FileExists(modulesPath) { continue } walkFunc := filepath.WalkDir if repo.walkWithSymlinks { walkFunc = util.WalkDirWithSymlinks } err := walkFunc(modulesPath, func(dir string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } moduleDir, err := filepath.Rel(repo.path, dir) if err != nil { return errors.New(err) } moduleDir = filepath.ToSlash(moduleDir) if module, err := NewModule(repo, moduleDir); err != nil { return err } else if module != nil { modules = append(modules, module) } return nil }) if err != nil { return nil, err } } return modules, nil } var githubEnterprisePatternReg = regexp.MustCompile(githubEnterpriseRegex) var gitlabSelfHostedPatternReg = regexp.MustCompile(gitlabSelfHostedRegex) // ModuleURL returns the URL to view this module in a browser. // When the module provided is in a format that is not supported by the catalog, it returns an empty string. func (repo *Repo) ModuleURL(moduleDir string) string { if repo.RemoteURL == "" { return filepath.Join(repo.path, moduleDir) } remote, err := vcsurl.Parse(repo.RemoteURL) if err != nil { return "" } // Simple, predictable hosts switch remote.Host { case githubHost: return fmt.Sprintf("https://%s/%s/tree/%s/%s", remote.Host, remote.FullName, repo.BranchName, moduleDir) case gitlabHost: return fmt.Sprintf("https://%s/%s/-/tree/%s/%s", remote.Host, remote.FullName, repo.BranchName, moduleDir) case bitbucketHost: return fmt.Sprintf("https://%s/%s/browse/%s?at=%s", remote.Host, remote.FullName, moduleDir, repo.BranchName) case azuredevHost: return fmt.Sprintf("https://%s/_git/%s?path=%s&version=GB%s", remote.Host, remote.FullName, moduleDir, repo.BranchName) } // // Hosts that require special handling if githubEnterprisePatternReg.MatchString(string(remote.Host)) { return fmt.Sprintf("https://%s/%s/tree/%s/%s", remote.Host, remote.FullName, repo.BranchName, moduleDir) } if gitlabSelfHostedPatternReg.MatchString(string(remote.Host)) { return fmt.Sprintf("https://%s/%s/-/tree/%s/%s", remote.Host, remote.FullName, repo.BranchName, moduleDir) } return "" } type CloneOptions struct { Context context.Context Logger log.Logger SourceURL string TargetPath string } func (repo *Repo) clone(ctx context.Context, l log.Logger) error { cloneURL := repo.resolveCloneURL() // Handle local directory case if files.IsDir(cloneURL) { return repo.handleLocalDir(cloneURL) } // Prepare clone options opts := CloneOptions{ SourceURL: cloneURL, TargetPath: repo.path, Context: ctx, Logger: repo.logger, } if err := repo.prepareCloneDirectory(); err != nil { return err } if repo.cloneCompleted() { repo.logger.Debugf("The repo dir exists and %q exists. Skipping cloning.", cloneCompleteSentinel) return nil } return repo.performClone(ctx, l, &opts) } func (repo *Repo) resolveCloneURL() string { if repo.cloneURL == "" { return repo.rootWorkingDir } return repo.cloneURL } func (repo *Repo) handleLocalDir(repoPath string) error { if !filepath.IsAbs(repoPath) { absRepoPath := filepath.Join(repo.rootWorkingDir, repoPath) repo.logger.Debugf("Converting relative path %q to absolute %q", repoPath, absRepoPath) repo.path = absRepoPath return nil } repo.path = repoPath return nil } func (repo *Repo) prepareCloneDirectory() error { if err := os.MkdirAll(repo.path, os.ModePerm); err != nil { return errors.New(err) } repoName := repo.extractRepoName() repo.path = filepath.Join(repo.path, repoName) // Clean up incomplete clones if repo.shouldCleanupIncompleteClone() { repo.logger.Debugf("The repo dir exists but %q does not. Removing the repo dir for cloning from the remote source.", cloneCompleteSentinel) if err := os.RemoveAll(repo.path); err != nil { return errors.New(err) } } return nil } func (repo *Repo) extractRepoName() string { repoName := "temp" if match := repoNameFromCloneURLReg.FindStringSubmatch(repo.cloneURL); len(match) > 0 && match[1] != "" { repoName = match[1] } return repoName } func (repo *Repo) shouldCleanupIncompleteClone() bool { return files.FileExists(repo.path) && !repo.cloneCompleted() } func (repo *Repo) cloneCompleted() bool { return files.FileExists(filepath.Join(repo.path, cloneCompleteSentinel)) } func (repo *Repo) performClone(ctx context.Context, l log.Logger, opts *CloneOptions) error { client := getter.DefaultClient if repo.allowCAS { c, err := cas.New(cas.Options{}) if err != nil { return err } cloneOpts := cas.CloneOptions{ Dir: repo.path, IncludedGitFiles: includedGitFiles, } client.Getters = append([]getter.Getter{cas.NewCASGetter(l, c, &cloneOpts)}, client.Getters...) } sourceURL, err := tf.ToSourceURL(opts.SourceURL, "") if err != nil { return err } repo.cloneURL = sourceURL.String() opts.Logger.Infof("Cloning repository %q to temporary directory %q", repo.cloneURL, repo.path) // Check first if the query param ref is already set q := sourceURL.Query() ref := q.Get("ref") if ref == "" { q.Set("ref", "HEAD") } sourceURL.RawQuery = q.Encode() _, err = client.Get(ctx, &getter.Request{ Src: sourceURL.String(), Dst: repo.path, GetMode: getter.ModeDir, }) if err != nil { return err } // Create the sentinel file to indicate that the clone is complete f, err := os.Create(filepath.Join(repo.path, cloneCompleteSentinel)) if err != nil { return errors.New(err) } if err := f.Close(); err != nil { return errors.New(err) } return nil } // parseRemoteURL reads the git config `.git/config` and parses the first URL of the remote URLs, the remote name "origin" has the highest priority. func (repo *Repo) parseRemoteURL() error { gitConfigPath := filepath.Join(repo.path, ".git", "config") if !files.FileExists(gitConfigPath) { return errors.Errorf("the specified path %q is not a git repository (no .git/config file found)", repo.path) } repo.logger.Debugf("Parsing git config %q", gitConfigPath) inidata, err := ini.Load(gitConfigPath) if err != nil { return errors.New(err) } var sectionName string for _, name := range inidata.SectionStrings() { if !strings.HasPrefix(name, "remote") { continue } sectionName = name if sectionName == `remote "origin"` { break } } // no git remotes found if sectionName == "" { return nil } repo.RemoteURL = inidata.Section(sectionName).Key("url").String() repo.logger.Debugf("Remote url: %q for repo: %q", repo.RemoteURL, repo.path) return nil } func (repo *Repo) gitHeadfile() string { return filepath.Join(repo.path, ".git", "HEAD") } // parseBranchName reads `.git/HEAD` file and parses a branch name. func (repo *Repo) parseBranchName() error { data, err := files.ReadFileAsString(repo.gitHeadfile()) if err != nil { return errors.Errorf("the specified path %q is not a git repository (no .git/HEAD file found)", repo.path) } if match := gitHeadBranchNameReg.FindStringSubmatch(data); len(match) > 0 { repo.BranchName = strings.TrimSpace(match[1]) return nil } return errors.Errorf("could not get branch name for repo %q", repo.path) } ================================================ FILE: internal/services/catalog/module/repo_test.go ================================================ package module_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFindModules(t *testing.T) { t.Parallel() type moduleData struct { title string description string url string moduleDir string } testCases := []struct { expectedErr error repoPath string expectedData []moduleData }{ { repoPath: "testdata/find_modules", expectedData: []moduleData{ { title: "ALB Ingress Controller Module", description: "This Terraform Module installs and configures the AWS ALB Ingress Controller on an EKS cluster, so that you can configure an ALB using Ingress resources.", url: "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-alb-ingress-controller", moduleDir: "modules/eks-alb-ingress-controller", }, { title: "ALB Ingress Controller IAM Policy Module", description: "This Terraform Module defines an IAM policy that defines the minimal set of permissions necessary for the AWS ALB Ingress Controller.", url: "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-alb-ingress-controller-iam-policy", moduleDir: "modules/eks-alb-ingress-controller-iam-policy", }, { title: "EKS AWS Auth Merger", description: "This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes.", url: "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-aws-auth-merger", moduleDir: "modules/eks-aws-auth-merger", }}, }, } for _, tc := range testCases { t.Run(tc.repoPath, func(t *testing.T) { t.Parallel() // Unfortunately, we are unable to commit the `.git` directory. We have to temporarily rename it while running the tests. os.Rename(filepath.Join(tc.repoPath, "gitdir"), filepath.Join(tc.repoPath, ".git")) defer os.Rename(filepath.Join(tc.repoPath, ".git"), filepath.Join(tc.repoPath, "gitdir")) ctx := t.Context() repo, err := module.NewRepo(ctx, logger.CreateLogger(), tc.repoPath, "", false, false, "") require.NoError(t, err) modules, err := repo.FindModules(ctx) assert.Equal(t, tc.expectedErr, err) realData := make([]moduleData, 0, len(modules)) for _, module := range modules { realData = append(realData, moduleData{ title: module.Title(), description: module.Description(), url: module.URL(), moduleDir: module.ModuleDir(), }) } assert.Equal(t, tc.expectedData, realData) }) } } func TestModuleURL(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error repo *module.Repo name string moduleDir string expectedURL string }{ { name: "github", repo: newRepo(t, "https://github.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://github.com/acme/terraform-aws-modules/tree/main/.", }, { name: "github enterprise", repo: newRepo(t, "https://github.acme.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://github.acme.com/acme/terraform-aws-modules/tree/main/.", }, { name: "gitlab", repo: newRepo(t, "https://gitlab.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://gitlab.com/acme/terraform-aws-modules/-/tree/main/.", }, { name: "gitlab self-hosted", repo: newRepo(t, "https://gitlab.acme.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://gitlab.acme.com/acme/terraform-aws-modules/-/tree/main/.", }, { name: "bitbucket", repo: newRepo(t, "https://bitbucket.org/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://bitbucket.org/acme/terraform-aws-modules/browse/.?at=main", }, { name: "azuredev", repo: newRepo(t, "https://dev.azure.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "https://dev.azure.com/_git/acme/terraform-aws-modules?path=.&version=GBmain", }, { name: "unsupported", repo: newRepo(t, "https://fake.com/acme/terraform-aws-modules"), moduleDir: ".", expectedURL: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() url := tc.repo.ModuleURL(tc.moduleDir) assert.Equal(t, tc.expectedURL, url) }) } } func newRepo(t *testing.T, url string) *module.Repo { t.Helper() return &module.Repo{ RemoteURL: url, BranchName: "main", } } ================================================ FILE: internal/services/catalog/module/testdata/find_modules/gitdir/HEAD ================================================ ref: refs/heads/master ================================================ FILE: internal/services/catalog/module/testdata/find_modules/gitdir/config ================================================ [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true ignorecase = true precomposeunicode = true [remote "origin"] url = https://github.com/gruntwork-io/terraform-aws-eks fetch = +refs/heads/*:refs/remotes/origin/* [branch "master"] remote = origin merge = refs/heads/master ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller/README.md ================================================ # ALB Ingress Controller Module This Terraform Module installs and configures the [AWS ALB Ingress Controller](https://github.com/kubernetes-sigs/aws-alb-ingress-controller) on an EKS cluster, so that you can configure an ALB using [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) resources. This module uses the [community helm chart](https://github.com/aws/eks-charts), with a set of best practices input. #### Note: v2 We're now supporting v2 of the AWS Load Balancer Ingress Controller. The AWS Load Balancer Ingress Controller v2 has many new features, and is considered backwards incompatible with the existing AWS resources it manages. Please note, that it can't coexist with the existing/older version, so you must fully undeploy the old version prior to updating. For the migration steps, please refer to the [relevant Release notes for this module](https://github.com/gruntwork-io/terraform-aws-eks/releases/tag/v0.28.0). ## How does this work? This module solves the problem of integrating Kubernetes `Service` endpoints with an [ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). Out of the box Kubernetes supports tying [a `Service` to an ELB or NLB using the `LoadBalancer` type](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/). However, the `LoadBalancer` `Service` type does not support ALBs, and thus you can not implement complex routing rules based on domain or paths. Kubernetes uses `Ingress` resources to configure and implement "Layer 7" load balancers (where ALBs fit in the [OSI model](https://en.wikipedia.org/wiki/OSI_model#Layer_7:_Application_Layer)). Kubernetes `Ingress` works by providing a configuration framework to configure routing rules from a load balancer to `Services` within Kubernetes. For example, suppose you wanted to provision a `Service` for your backend, fronted by a load balancer that routes any request made to the path `/service` to the backend. To do so, in addition to creating your `Service`, you would create an `Ingress` resource in Kubernetes that configures the routing rule: ```yaml --- kind: Service apiVersion: v1 metadata: name: backend spec: selector: app: backend ports: - protocol: TCP port: 80 targetPort: 80 --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: service-ingress spec: rules: - http: paths: - path: /service backend: serviceName: backend servicePort: 80 ``` In the above configuration, we create a Cluster IP based `Service` (so that it is only available internally to the Kubernetes cluster) that routes requests to port 80 to any `Pod` that matches the label `app=backend` on port 80. Then, we configure an `Ingress` rule that routes any requests prefixed with `/service` to that `Service` endpoint on port 80. The actual load balancer that is configured by the `Ingress` resource is defined by the particular [Ingress Controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) that you deploy onto your Kubernetes cluster. Ingress Controllers are separate processes that run on your Kubernetes cluster that will watch for `Ingress` resources and reflect them by provisioning or configuring load balancers. Depending on which controller you use, the particular load balancer that is provisioned will be different. For example, if you use the [official nginx controller](https://github.com/kubernetes/ingress-nginx/blob/e222b74/README.md), each `Ingress` resource translates into an nginx `Pod` that implements the routing rules. Note that each `Ingress` resource defines a separate load balancer. This means that each time you create a new `Ingress` resource in Kubernetes, Kubernetes will provision a new load balancer configured with the rules defined by the `Ingress` resource. This module deploys the AWS ALB Ingress Controller, which will reflect each `Ingress` resource into an ALB resource deployed into your AWS account. ## How do you use this module? * See the [root README](/README.adoc) for instructions on using Terraform modules. * See the [eks-cluster-with-supporting-services example](/examples/eks-cluster-with-supporting-services) for example usage. * See [variables.tf](./variables.tf) for all the variables you can set on this module. * This module uses [the `kubernetes` provider](https://www.terraform.io/docs/providers/kubernetes/index.html). * This module uses [the `helm` provider](https://www.terraform.io/docs/providers/helm/index.html). ## Prerequisites ### Helm setup This module uses [`helm` v3](https://helm.sh/docs/) to deploy the controller to the Kubernetes cluster. ### ALB Target Type The ALB Ingress Controller application can configure ALBs to send work either to Node IPs (`instance`) or Pod IPs (`ip`) as backend targets. This can be specified in the Ingress object using the [`alb.ingress.kubernetes.io/target-type`](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/ingress/annotation/#target-type). The default is `instance`. When using the default `instance` target type, the `Services` intended to be consumed by the `Ingress` resource must be provisioned using the `NodePort` type. This is not required when using the `ip` target type. Note that the controller will take care of setting up the target groups on the provisioned ALB so that everything routes correctly. ### Subnets You can use the `alb.ingress.kubernetes.io/subnets` annotation on `Ingress` resources to specify which subnets the controller should configure the ALB for. You can also omit the `alb.ingress.kubernetes.io/subnets` annotation, and the controller will [automatically discover subnets](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/controller/config/#subnet-auto-discovery) based on their tags. This method should work "out of the box", so long as you are using the [`eks-vpc-tags`](../eks-vpc-tags) module to tag your VPC subnets. ### Security Groups As mentioned above under the [ALB Target Type](#alb-target-type) section, the default ALB target type uses node ports to connect to the `Services`. As such if you have restricted security groups that prevent access to the provisioned ports on the worker nodes, the ALBs will not be able to reach the `Services`. To ensure the provisioned ALBs can access the node ports, we recommend using dedicated subnets for load balancing and configuring your security groups so that resources provisioned in those subnets can access the node ports of the worker nodes. ### IAM permissions The container deployed in this module requires IAM permissions to manage ALB resources. See [the eks-alb-ingress-controller-iam-policy module](../eks-alb-ingress-controller-iam-policy) for more information. ## Using the Ingress Controller In order for the `Ingress` resources to properly map into an ALB, the `Ingress` resources created need to be annotated to use the `alb` `Ingress` class. You can do this by adding the following annotation to your `Ingress` resources: ```yaml annotations: kubernetes.io/ingress.class: alb ``` The ALB Ingress Controller supports a wide range of configuration options via annotations on the `Ingress` object, including setting up Cognito for authentication. For example, you can add the annotation `alb.ingress.kubernetes.io/scheme: internet-facing` to provision a public ALB. You can refer to the [official documentation](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/ingress/annotation/) for the full reference of configuration options supported by the controller. ## Getting the ALB endpoint The ALB endpoint is recorded on the `Ingress` resource. You can use `kubectl` or the Kubernetes API to retrieve the `Ingress` resource and view the endpoint for the ALB under the `Address` attribute. For example, suppose you provisioned the following `Ingress` resource in the default namespace: ```yaml --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: service-ingress annotations: kubernetes.io/ingress.class: alb spec: rules: - http: paths: - path: /service backend: serviceName: backend servicePort: 80 ``` To get the ALB endpoint, call `kubectl` to describe the `Ingress` resource: ``` $ kubectl describe ing service-ingress Name: service-ingress Namespace: default Address: QZVpvauzhSuRBRMfjAGnbgaCaLeANaoe.us-east-2.elb.amazonaws.com Default backend: default-http-backend:80 (10.2.1.28:8080) Rules: Host Path Backends ---- ---- -------- /service backend:80 () Annotations: Events: FirstSeen LastSeen Count From SubObjectPath Type Reason Message --------- -------- ----- ---- ------------- -------- ------ ------- 3m 3m 1 ingress-controller Normal CREATE Ingress service-ingress/backend 3m 32s 3 ingress-controller Normal UPDATE Ingress service-ingress/backend ``` Note how the ALB endpoint is recorded under the `Address` column. You can hit that endpoint to access the service externally. ## DNS records for the ALB In order for the host based routing rules to work with the ALB, you need to configure your DNS records to point to the ALB endpoint. This can be tricky if you are managing your DNS records externally, especially given the asynchronous nature of the controller in provisioning the ALBs. The AWS ALB Ingress Controller has first class support for [external-dns](https://github.com/kubernetes-incubator/external-dns), a third party tool that configures external DNS providers with domains to route to `Services` and `Ingresses` in Kubernetes. See our [eks-k8s-external-dns module](../eks-k8s-external-dns) for more information on how to setup the tool. ## How do I deploy the Pods to Fargate? To deploy the Pods to Fargate, you can use the `create_fargate_profile` variable to `true` and specify the subnet IDs for Fargate using `vpc_worker_subnet_ids`. Note that if you are using Fargate, you must rely on the IAM Roles for Service Accounts (IRSA) feature to grant the necessary AWS IAM permissions to the Pod. This is configured using the `use_iam_role_for_service_accounts`, `eks_openid_connect_provider_arn`, and `eks_openid_connect_provider_url` input variables. ## How does the ALB route to Fargate? For Pods deployed to Fargate, you must specify the annotation ``` alb.ingress.kubernetes.io/target-type: ip ``` to the Ingress resource in order for the ALB to route properly. This is because Fargate does not have actual EC2 instances under the hood, and thus the ALB can not be configured to route by instance (the default configuration). ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller/main.tf ================================================ ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller/variables.tf ================================================ ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller-iam-policy/README.md ================================================ # ALB Ingress Controller IAM Policy Module This Terraform Module defines an [IAM policy](http://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/QuickStartEC2Instance.html#d0e22325) that defines the minimal set of permissions necessary for the [AWS ALB Ingress Controller](https://github.com/kubernetes-sigs/aws-alb-ingress-controller). This policy can then be attached to EC2 instances or IAM roles so that the controller deployed has enough permissions to manage an ALB. See [the eks-alb-ingress-controller module](/modules/eks-alb-ingress-controller) for a module that deploys the Ingress Controller on to your EKS cluster. ## How do you use this module? * See the [root README](/README.adoc) for instructions on using Terraform modules. * See the [eks-cluster-with-supporting-services example](/examples/eks-cluster-with-supporting-services) for example usage. * See [variables.tf](./variables.tf) for all the variables you can set on this module. * See [outputs.tf](./outputs.tf) for all the variables that are outputted by this module. ## Attaching IAM policy to workers To allow the ALB Ingress Controller to manage ALBs, it needs IAM permissions to use the AWS API to manage ALBs. Currently, the way to grant Pods IAM privileges is to use the worker IAM profiles provisioned by [the eks-cluster-workers module](/modules/eks-cluster-workers/README.md#how-do-you-add-additional-iam-policies). The Terraform templates in this module create an IAM policy that has the required permissions. You then need to use an [aws_iam_policy_attachment](https://www.terraform.io/docs/providers/aws/r/iam_policy_attachment.html) to attach that policy to the IAM roles of your EC2 Instances. ```hcl module "eks_workers" { # (arguments omitted) } module "alb_ingress_controller_iam_policy" { # (arguments omitted) } resource "aws_iam_role_policy_attachment" "attach_alb_ingress_controller_iam_policy" { role = "${module.eks_workers.eks_worker_iam_role_name}" policy_arn = "${module.alb_ingress_controller_iam_policy.alb_ingress_controller_policy_arn}" } ``` ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller-iam-policy/main.tf ================================================ ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-alb-ingress-controller-iam-policy/variables.tf ================================================ ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-aws-auth-merger/README.adoc ================================================ :type: service :name: EKS AWS Auth Merger :description: Manage the aws-auth ConfigMap across multiple independent ConfigMaps. :icon: /_docs/iam-role-icon.png :category: docker-orchestration :cloud: aws :tags: docker, orchestration, kubernetes, containers :license: gruntwork :built-with: go, terraform // AsciiDoc TOC settings :toc: :toc-placement!: :toc-title: // GitHub specific settings. See https://gist.github.com/dcode/0cfbf2699a1fe9b46ff04c41721dda74 for details. ifdef::env-github[] :tip-caption: :bulb: :note-caption: :information_source: :important-caption: :heavy_exclamation_mark: :caution-caption: :fire: :warning-caption: :warning: endif::[] = EKS AWS Auth Merger image:https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg[link="https://gruntwork.io/?ref=repo_aws_eks"] image:https://img.shields.io/badge/tf-%3E%3D1.1.0-blue[Terraform version] image:https://img.shields.io/badge/k8s-1.24%20~%201.28-5dbcd2[K8s version] This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes. The official way to manage the mapping is to add values in a single, central `ConfigMap`. This module allows you to break up the central `ConfigMap` across multiple, separate `ConfigMaps` each configuring a subset of the mappings you ultimately want to use, allowing you to update entries in the `ConfigMap` in isolated modules (e.g., when you add a new IAM role in a separate module from the EKS cluster). The `aws-auth-merger` watches for `aws-auth` compatible `ConfigMaps` that can be merged to manage the `aws-auth` authentication `ConfigMap` for EKS. toc::[] == Features * Break up the `aws-auth` Kubernetes `ConfigMap` across multiple objects. * Automatically merge new `ConfigMaps` as they are added and removed. * Track automatically generated `aws-auth` source `ConfigMaps` that are generated by EKS. == Learn NOTE: This repo is a part of https://gruntwork.io/infrastructure-as-code-library/[the Gruntwork Infrastructure as Code Library], a collection of reusable, battle-tested, production ready infrastructure code. If you've never used the Infrastructure as Code Library before, make sure to read https://gruntwork.io/guides/foundations/how-to-use-gruntwork-infrastructure-as-code-library/[How to use the Gruntwork Infrastructure as Code Library]! === Core concepts * _link:/modules/eks-k8s-role-mapping/README.md#what-is-kubernetes-role-based-access-control-rbac[What is Kubernetes RBAC?]_: overview of Kubernetes RBAC, the underlying system managing authentication and authorization in Kubernetes. * _link:/modules/eks-k8s-role-mapping/README.md#what-is-aws-iam-role[What is AWS IAM role?]_: overview of AWS IAM Roles, the underlying system managing authentication and authorization in AWS. * _https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html[Managing users or IAM roles for your cluster]_: The official AWS docs on how the `aws-auth` Kubernetes `ConfigMap` works. * _link:core-concepts.md#what-is-the-aws-auth-merger[What is the aws-auth-merger?]_: overview of the `aws-auth-merger` and how it works to manage the `aws-auth` Kubernetes `ConfigMap`. === Repo organization * link:/modules[modules]: the main implementation code for this repo, broken down into multiple standalone, orthogonal submodules. * link:/examples[examples]: This folder contains working examples of how to use the submodules. * link:/test[test]: Automated tests for the modules and examples. == Deploy === Non-production deployment (quick start for learning) If you just want to try this repo out for experimenting and learning, check out the following resources: * link:/examples[examples folder]: The `examples` folder contains sample code optimized for learning, experimenting, and testing (but not production usage). === Production deployment If you want to deploy this repo in production, check out the following resources: * https://gruntwork.io/guides/kubernetes/how-to-deploy-production-grade-kubernetes-cluster-aws/#deployment_walkthrough[How to deploy a production-grade Kubernetes cluster on AWS]: A step-by-step guide for deploying a production-grade EKS cluster on AWS using the code in this repo. **EKS Cluster**: Production-ready example code from the Reference Architecture: * https://github.com/gruntwork-io/terraform-aws-service-catalog/blob/main/examples/for-production/infrastructure-live/prod/us-west-2/prod/services/eks-cluster/terragrunt.hcl[app account configuration] * https://github.com/gruntwork-io/terraform-aws-service-catalog/blob/main/examples/for-production/infrastructure-live/_envcommon/services/eks-cluster.hcl[base configuration] == Manage * link:core-concepts.md#how-do-i-use-the-aws-auth-merger[How to deploy and use the aws-auth-merger] * link:core-concepts.md#how-do-i-handle-conflicts-with-automatic-updates-by-eks[How to handle conflicts with automatic updates to the aws-auth ConfigMap by EKS] * link:/modules/eks-k8s-role-mapping/README.md#restricting-specific-actions[How to restrict users to specific actions on the EKS cluster] * link:/modules/eks-k8s-role-mapping/README.md#restricting-by-namespace[How to restrict users to specific namespaces on the EKS cluster] * link:/core-concepts.md#how-to-authenticate-kubectl[How to authenticate kubectl to EKS] == Support If you need help with this repo or anything else related to infrastructure or DevOps, Gruntwork offers https://gruntwork.io/support/[Commercial Support] via Slack, email, and phone/video. If you're already a Gruntwork customer, hop on Slack and ask away! If not, https://www.gruntwork.io/pricing/[subscribe now]. If you're not sure, feel free to email us at link:mailto:support@gruntwork.io[support@gruntwork.io]. == Contributions Contributions to this repo are very welcome and appreciated! If you find a bug or want to add a new feature or even contribute an entirely new module, we are very happy to accept pull requests, provide feedback, and run your changes through our automated test suite. Please see https://gruntwork.io/guides/foundations/how-to-use-gruntwork-infrastructure-as-code-library/#contributing-to-the-gruntwork-infrastructure-as-code-library[Contributing to the Gruntwork Infrastructure as Code Library] for instructions. == License Please see link:LICENSE.md[LICENSE.md] for details on how the code in this repo is licensed. ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-aws-auth-merger/core-concepts.md ================================================ ## What is the aws-auth-merger? The `aws-auth-merger` is a go CLI intended to be run inside a Pod in an EKS cluster (as opposed to a CLI tool used by the operator) for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes, and is an alternative to [the official way AWS recommends managing the mappings](https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html). The official way to manage the mapping is to add values in a single, central `ConfigMap`. This central `ConfigMap` has a few challenges: - The updates are not managed as code if you are manually updating the `ConfigMap`. This can be a problem when you want to spin up a new cluster with the same configuration, as you now have to download the `ConfigMap` and replicate it into the new cluster. - The [eks-k8s-role-mapping module](../eks-k8s-role-mapping) allows you to manage the central `ConfigMap` as code. However, EKS will create the `ConfigMap` under certain conditions (e.g. to allow access to Fargate), and depending on timing, you can end up with an error where terraform is not able to create the `ConfigMap` until you import it into the state. - A single typo or mistake can disable the entire `ConfigMap`. For example, if you have a syntactic yaml error in the central `ConfigMap`, it will prevent EKS from being able to read the `ConfigMap`, thereby disabling access to all the users captured in the `ConfigMap`. The `aws-auth-merger` can be used to address these challenges by breaking up the central `ConfigMap` across multiple `ConfigMaps` that are tracked in a separate place. The `aws-auth-merger` watches for `aws-auth` compatible `ConfigMaps` that can be merged to manage the `aws-auth` authentication `ConfigMap` for EKS. The `aws-auth-merger` works as follows: - When starting up, the `aws-auth-merger` will scan if the main `aws-auth` `ConfigMap` already exists in the `kube-system` namespace. The `aws-auth-merger` checks if the `ConfigMap` was created by the merger, and if not, will snapshot the `ConfigMap` so that it will be included in the merge. - The `aws-auth-merger` then does an initial merger of all the `ConfigMaps` in the configured namespace to create the initial version of the main `aws-auth` `ConfigMap`. - The `aws-auth-merger` then enters an infinite event loop that watches for changes to the `ConfigMaps` in the configured namespace. The syncing routine will run every time the merger detects changes in the namespace. ## How do I use the aws-auth-merger? To deploy the `aws-auth-merger`, follow the following steps: 1. Create a docker repository to house the `aws-auth-merger`. We recommend using ECR. 1. Build a Docker image that runs the `aws-auth-merger` and push the container to ECR. 1. Deploy this module using terraform: 1. Set the `aws_auth_merger_image` variable to point to the ECR repo and tag for the `aws-auth-merger` docker image. 1. Set additional variables as needed. If you wish to manually deploy the `aws-auth-merger` without using Terraform, you can deploy a `Deployment` with a single replica using the image. The `ServiceAccount` that you associate with the `Pods` in the `Deployment` needs to be able to: - `get`, `list`, `create`, and `watch` for `ConfigMaps` in the namespace that it is watching. - `get`, `create`, and `update` the `aws-auth` `ConfigMap` in the `kube-system`. Once the `aws-auth-merger` is deployed, you can create `ConfigMaps` in the watched namespace that mimic the `aws-auth` `ConfigMap`. Refer to [the official AWS docs](https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html) for more information on the format of the `aws-auth` `ConfigMap`. For convenience, you can use the [eks-k8s-role-mapping](../eks-k8s-role-mapping) module to manage each individual `aws-auth` `ConfigMap` to be merged by the merger. Refer to the [eks-cluster-with-iam-role-mappings example](/example/eks-cluster-with-iam-role-mappings) for an example of how to integrate the two modules. ## How do I handle conflicts with automatic updates by EKS? EKS will automatically update or create the central `aws-auth` `ConfigMap`. This can lead to conflicts with the `aws-auth-merger`, including potential data loss that locks out Fargate or Managed Node Group workers. To handle these conflicts, we recommend the following approach: - If you are using Fargate for the Control Plane components (e.g. CoreDNS) or for the `aws-auth-merger` itself, ensure that the relevant Fargate Profiles are created prior to the initial deployment of the `aws-auth-merger`. This ensures that AWS constructs the `aws-auth` `ConfigMap` before the `aws-auth-merger` comes online, allowing it to snapshot the existing `ConfigMap` to be merged in to the managed central `ConfigMap`. - If you are using Fargate outside of the `aws-auth-merger`, ensure that you create the Fargate Profile after the `aws-auth-merger` is deployed. Then, create an `aws-auth` `ConfigMap` in the merger namespace that includes the Fargate execution role (the input variable `eks_fargate_profile_executor_iam_role_arns` in the `eks-k8s-role-mapping` module). This ensures that the Fargate execution role is included in the merged `ConfigMap`. - If you are using Managed Node Groups, you have two options: - Ensure that the Managed Node Group is created prior to the `aws-auth-merger` being deployed. This ensures that AWS constructs the `aws-auth` `ConfigMap` before the `aws-auth-merger` comes online. - If you wish to create Managed Node Groups after the `aws-auth-merger` is deployed, ensure that the worker IAM role of the Managed Node Group is included in an `aws-auth` `ConfigMap` in the merger namespace (the input variable `eks_worker_iam_role_arns`). ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-aws-auth-merger/main.tf ================================================ ================================================ FILE: internal/services/catalog/module/testdata/find_modules/modules/eks-aws-auth-merger/variables.tf ================================================ ================================================ FILE: internal/shell/error_explainer.go ================================================ package shell import ( "fmt" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/errors" ) // terraformErrorsMatcher List of errors that we know how to explain to the user. The key is a regex that matches the error message, and the value is the explanation. var terraformErrorsMatcher = map[string]string{ "(?s).*Error refreshing state: AccessDenied: Access Denied(?s).*": "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", "(?s).*AllAccessDisabled: All access to this object has been disabled(?s).*": "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", "(?s).*operation error S3: ListObjectsV2, https response error StatusCode: 301(?s).*": "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", "(?s).*The authorization header is malformed(?s).*": "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", "(?s).*Unable to list objects in S3 bucket(?s).*": "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", "(?s).*Error: Initialization required(?s).*": "You need to run terragrunt (run --all) init to initialize working directory.", "(?s).*Unit source has changed(?s).*": "You need to run terragrunt (run --all) init install all required modules.", "(?s).*Error finding AWS credentials(?s).*": "Missing AWS credentials. Provide credentials to proceed.", "(?s).*Error: No valid credential sources found(?s).*": "Missing AWS credentials. Provide credentials to proceed.", "(?s).*Error: validating provider credentials(?s).*": "Missing AWS credentials. Provide credentials to proceed.", "(?s).*NoCredentialProviders(?s).*": "Missing AWS credentials. Provide credentials to proceed.", "(?s).*client: no valid credential sources(?s).*": "Missing AWS credentials. Provide credentials to proceed.", "(?s).*exec: \"(tofu|terraform)\": executable file not found(?s).*": "The executables 'terraform' and 'tofu' are missing from your $PATH. Please add at least one of these to your $PATH.", "(?s).*bucket must have been previously created.*": "Remote state bucket not found, create it manually or rerun with --backend-bootstrap to provision automatically.", "(?s).*specified bucket does not exist.*": "Remote state bucket not found, create it manually or rerun with --backend-bootstrap to provision automatically.", "(?s).*S3 bucket does not exist.*": "Remote state bucket not found, create it manually or rerun with --backend-bootstrap to provision automatically.", } // ExplainError will try to explain the error to the user, if we know how to do so. func ExplainError(err error) string { explanations := map[string]string{} // iterate over each error, unwrap it, and check for error output for _, err := range errors.UnwrapErrors(err) { message := err.Error() // extract process output, if it is the case var processError util.ProcessExecutionError if ok := errors.As(err, &processError); ok { errorOutput := processError.Output.Stderr.String() stdOut := processError.Output.Stdout.String() message = fmt.Sprintf("%s\n%s", stdOut, errorOutput) } for regex, explanation := range terraformErrorsMatcher { if match, _ := regexp.MatchString(regex, message); match { // collect matched explanations explanations[explanation] = "1" } } } return strings.Join(collections.Keys(explanations), "\n") } ================================================ FILE: internal/shell/error_explainer_test.go ================================================ package shell_test import ( "bytes" "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" ) func TestExplainError(t *testing.T) { t.Parallel() var testCases = []struct { errorOutput string explanation string }{ { errorOutput: "Error refreshing state: AccessDenied: Access Denied", explanation: "Check your credentials and permissions", }, { errorOutput: "Error: Initialization required", explanation: "You need to run terragrunt (run --all) init to initialize working directory", }, { errorOutput: "Unit source has changed", explanation: "You need to run terragrunt (run --all) init install all required modules", }, { errorOutput: "Error: Failed to get existing workspaces: Unable to list objects in S3 bucket \"mybucket\": operation error S3: ListObjectsV2, https response error StatusCode: 301, RequestID: GH67DSB7KB8H578N, HostID: vofohiXBwNhR8Im+Dj7RpUPCPnOq9IDfn1rsUHHCzN9HgVMFfuIH5epndgLQvDeJPz2DrlUh0tA=, requested bucket from \"us-east-1\", actual location \"eu-west-1\"\n", explanation: "You don't have access to the S3 bucket where the state is stored. Check your credentials and permissions.", }, { errorOutput: "exec: \"tofu\": executable file not found in $PATH", explanation: "The executables 'terraform' and 'tofu' are missing from your $PATH. Please add at least one of these to your $PATH.", }, } for _, tt := range testCases { t.Run(tt.errorOutput, func(t *testing.T) { t.Parallel() output := util.CmdOutput{} output.Stderr = *bytes.NewBufferString(tt.errorOutput) errs := new(errors.MultiError) errs = errs.Append(util.ProcessExecutionError{ Err: errors.New(""), Output: output, }) explanation := shell.ExplainError(errs) assert.Contains(t, explanation, tt.explanation) }) } } ================================================ FILE: internal/shell/git.go ================================================ package shell import ( "bytes" "context" "net/url" "strings" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-version" ) const ( gitPrefix = "git::" refsTags = "refs/tags/" tagSplitPart = 2 ) // GitTopLevelDir fetches git repository path from passed directory. func GitTopLevelDir(ctx context.Context, l log.Logger, env map[string]string, path string) (string, error) { runCache := cache.ContextCache[string](ctx, cache.RunCmdCacheContextKey) cacheKey := "top-level-dir-" + path if gitTopLevelDir, found := runCache.Get(ctx, cacheKey); found { return gitTopLevelDir, nil } stdout := bytes.Buffer{} stderr := bytes.Buffer{} gitRunOpts := &ShellOptions{ Writers: writer.Writers{Writer: &stdout, ErrWriter: &stderr}, WorkingDir: path, Env: env, } cmd, err := RunCommandWithOutput(ctx, l, gitRunOpts, path, true, false, "git", "rev-parse", "--show-toplevel") if err != nil { return "", err } cmdOutput := strings.TrimSpace(cmd.Stdout.String()) if stderrString := strings.TrimSpace(stderr.String()); stderrString != "" { l.Warnf("git rev-parse --show-toplevel resulted in stderr output: \n%v\n", stderrString) } l.Debugf("git show-toplevel result: %s", cmdOutput) runCache.Put(ctx, cacheKey, cmdOutput) return cmdOutput, nil } // GitRepoTags fetches git repository tags from passed url. func GitRepoTags(ctx context.Context, l log.Logger, env map[string]string, workingDir string, gitRepo *url.URL) ([]string, error) { repoPath := gitRepo.String() // remove git:: part if present repoPath = strings.TrimPrefix(repoPath, gitPrefix) stdout := bytes.Buffer{} stderr := bytes.Buffer{} gitRunOpts := &ShellOptions{ Writers: writer.Writers{Writer: &stdout, ErrWriter: &stderr}, WorkingDir: workingDir, Env: env, } output, err := RunCommandWithOutput(ctx, l, gitRunOpts, workingDir, true, false, "git", "ls-remote", "--tags", repoPath) if err != nil { return nil, errors.New(err) } var tags []string tagLines := strings.SplitSeq(output.Stdout.String(), "\n") for line := range tagLines { fields := strings.Fields(line) if len(fields) >= tagSplitPart { tags = append(tags, fields[1]) } } return tags, nil } // GitLastReleaseTag fetches git repository last release tag. func GitLastReleaseTag(ctx context.Context, l log.Logger, env map[string]string, workingDir string, gitRepo *url.URL) (string, error) { tags, err := GitRepoTags(ctx, l, env, workingDir, gitRepo) if err != nil { return "", err } if len(tags) == 0 { return "", nil } return LastReleaseTag(tags), nil } // LastReleaseTag returns last release tag from passed tags slice. func LastReleaseTag(tags []string) string { semverTags := extractSemVerTags(tags) if len(semverTags) == 0 { return "" } // find last semver tag lastVersion := semverTags[0] for _, ver := range semverTags { if ver.GreaterThanOrEqual(lastVersion) { lastVersion = ver } } return lastVersion.Original() } // extractSemVerTags - extract semver tags from passed tags slice. func extractSemVerTags(tags []string) []*version.Version { var semverTags []*version.Version for _, tag := range tags { t := strings.TrimPrefix(tag, refsTags) if v, err := version.NewVersion(t); err == nil { // consider only semver tags semverTags = append(semverTags, v) } } return semverTags } ================================================ FILE: internal/shell/prompt.go ================================================ package shell import ( "bufio" "context" "io" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/pkg/log" ) // PromptUserForInput prompts the user for text in the CLI. Returns the text entered by the user. func PromptUserForInput(ctx context.Context, l log.Logger, prompt string, nonInteractive bool, errWriter io.Writer) (string, error) { // We are writing directly to ErrWriter so the prompt is always visible // no matter what logLevel is configured. If `--non-interactive` is set, we log both prompt and // a message about assuming `yes` to Debug, so if nonInteractive { l.Debugf("%s", prompt) l.Debugf("The non-interactive flag is set to true, so assuming 'yes' for all prompts") return "yes", nil } n, err := errWriter.Write([]byte(prompt)) if err != nil { l.Error(err) return "", errors.New(err) } if n != len(prompt) { l.Errorln("Failed to write data") return "", errors.New(err) } exec.PrepareStdinForPrompt(l) reader := bufio.NewReader(os.Stdin) inputCh := make(chan string) errCh := make(chan error) go func() { input, err := reader.ReadString('\n') if err != nil { errCh <- errors.New(err) return } inputCh <- strings.TrimSpace(input) }() select { case <-ctx.Done(): return "", ctx.Err() case err := <-errCh: return "", err case input := <-inputCh: return input, nil } } // PromptUserForYesNo prompts the user for a yes/no response and return true if they entered yes. func PromptUserForYesNo(ctx context.Context, l log.Logger, prompt string, nonInteractive bool, errWriter io.Writer) (bool, error) { resp, err := PromptUserForInput(ctx, l, prompt+" (y/n) ", nonInteractive, errWriter) if err != nil { return false, errors.New(err) } switch strings.ToLower(resp) { case "y", "yes": return true, nil default: return false, nil } } ================================================ FILE: internal/shell/run_cmd.go ================================================ // Package shell provides functions to run shell commands and Terraform commands. package shell import ( "context" "fmt" "io" "strings" "time" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) // SignalForwardingDelay is the time to wait before forwarding the signal to the subcommand. // // The signal can be sent to the main process (only `terragrunt`) as well as the process group (`terragrunt` and `terraform`), for example: // kill -INT # sends SIGINT only to the main process // kill -INT - # sends SIGINT to the process group // Since we cannot know how the signal is sent, we should give `tofu`/`terraform` time to gracefully exit // if it receives the signal directly from the shell, to avoid sending the second interrupt signal to `tofu`/`terraform`. const SignalForwardingDelay = time.Second * 15 // ShellOptions contains the configuration needed to run shell commands. type ShellOptions struct { Writers writer.Writers EngineOptions *engine.EngineOptions EngineConfig *engine.EngineConfig Telemetry *telemetry.Options Env map[string]string RootWorkingDir string WorkingDir string TFPath string Experiments experiment.Experiments Headless bool ForwardTFStdout bool } // NoEngine returns true if the user explicitly disabled the engine via --no-engine. // Returns false when EngineOptions is nil (default: don't disable), letting the // other guards (EngineConfig != nil, experiment enabled) decide whether to run. func (o *ShellOptions) NoEngine() bool { return o.EngineOptions != nil && o.EngineOptions.NoEngine } // RunCommand runs the given shell command. func RunCommand(ctx context.Context, l log.Logger, runOpts *ShellOptions, command string, args ...string) error { _, err := RunCommandWithOutput(ctx, l, runOpts, "", false, false, command, args...) return err } // RunCommandWithOutput runs the specified shell command with the specified arguments. // // Connect the command's stdin, stdout, and stderr to // the currently running app. The command can be executed in a custom working directory by using the parameter // `workingDir`. Terragrunt working directory will be assumed if empty string. func RunCommandWithOutput( ctx context.Context, l log.Logger, runOpts *ShellOptions, workingDir string, suppressStdout bool, needsPTY bool, command string, args ...string, ) (*util.CmdOutput, error) { var ( output = util.CmdOutput{} commandDir = workingDir ) if workingDir == "" { commandDir = runOpts.WorkingDir } err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "run_"+command, map[string]any{ "command": command, "args": fmt.Sprintf("%v", args), "dir": commandDir, }, func(ctx context.Context) error { l.Debugf("Running command: %s %s", command, strings.Join(args, " ")) var ( cmdStderr = io.MultiWriter(runOpts.Writers.ErrWriter, &output.Stderr) cmdStdout = io.MultiWriter(runOpts.Writers.Writer, &output.Stdout) ) // Pass the traceparent to the child process if it is available in the context. traceParent := telemetry.TraceParentFromContext(ctx, runOpts.Telemetry) if traceParent != "" { l.Debugf("Setting trace parent=%q for command %s", traceParent, fmt.Sprintf("%s %v", command, args)) runOpts.Env[telemetry.TraceParentEnv] = traceParent } if suppressStdout { l.Debugf("Command output will be suppressed.") cmdStdout = io.MultiWriter(&output.Stdout) } if command == runOpts.TFPath { // If the engine is enabled and the command is IaC executable, use the engine to run the command. if runOpts.EngineConfig != nil && runOpts.Experiments.Evaluate(experiment.IacEngine) && !runOpts.NoEngine() { l.Debugf("Using engine to run command: %s %s", command, strings.Join(args, " ")) cmdOutput, err := engine.Run(ctx, l, &engine.ExecutionOptions{ Writers: writer.Writers{ Writer: writer.NewWrappedWriter(cmdStdout, runOpts.Writers.Writer), ErrWriter: writer.NewWrappedWriter(cmdStderr, runOpts.Writers.ErrWriter), LogShowAbsPaths: runOpts.Writers.LogShowAbsPaths, LogDisableErrorSummary: runOpts.Writers.LogDisableErrorSummary, }, EngineOptions: runOpts.EngineOptions, EngineConfig: runOpts.EngineConfig, Env: runOpts.Env, WorkingDir: commandDir, RootWorkingDir: runOpts.RootWorkingDir, Command: command, Args: args, Headless: runOpts.Headless, ForwardTFStdout: runOpts.ForwardTFStdout, SuppressStdout: suppressStdout, AllocatePseudoTty: needsPTY, }) if err != nil { return errors.New(err) } output = *cmdOutput return err } } cmd := exec.Command(ctx, command, args...) cmd.Dir = commandDir cmd.Stdout = cmdStdout cmd.Stderr = cmdStderr cmd.Configure( exec.WithLogger(l), exec.WithUsePTY(needsPTY), exec.WithEnv(runOpts.Env), exec.WithForwardSignalDelay(SignalForwardingDelay), ) // Save/restore console mode around subprocess — Windows subprocesses can reset it. savedConsole := exec.SaveConsoleState() defer savedConsole.Restore() if err := cmd.Start(); err != nil { //nolint:contextcheck // context already passed to exec.Command err = util.ProcessExecutionError{ Err: err, Args: args, Command: command, WorkingDir: cmd.Dir, RootWorkingDir: runOpts.RootWorkingDir, LogShowAbsPaths: runOpts.Writers.LogShowAbsPaths, DisableSummary: runOpts.Writers.LogDisableErrorSummary, } return errors.New(err) } cancelShutdown := cmd.RegisterGracefullyShutdown(ctx) defer cancelShutdown() if err := cmd.Wait(); err != nil { err = util.ProcessExecutionError{ Err: err, Args: args, Command: command, Output: output, WorkingDir: cmd.Dir, RootWorkingDir: runOpts.RootWorkingDir, LogShowAbsPaths: runOpts.Writers.LogShowAbsPaths, DisableSummary: runOpts.Writers.LogDisableErrorSummary, } return errors.New(err) } return nil }) return &output, err } ================================================ FILE: internal/shell/run_cmd_output_test.go ================================================ //go:build linux || darwin // +build linux darwin package shell_test import ( "bytes" "strings" "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/pkg/options" ) func TestCommandOutputOrder(t *testing.T) { t.Parallel() t.Run("withPtty", func(t *testing.T) { t.Parallel() testCommandOutputOrder(t, true, []string{"stdout1", "stderr1", "stdout2", "stderr2", "stderr3"}, []string{"stdout1", "stdout2"}, []string{"stderr1", "stderr2", "stderr3"}, ) }) t.Run("withoutPtty", func(t *testing.T) { t.Parallel() testCommandOutputOrder(t, false, []string{"stderr1", "stderr2", "stderr3"}, []string{"stdout1", "stdout2"}, []string{"stderr1", "stderr2", "stderr3"}, ) }) } func noop[T any](t T) {} func testCommandOutputOrder(t *testing.T, withPtty bool, fullOutput []string, stdout []string, stderr []string) { t.Helper() testCommandOutput(t, noop[*options.TerragruntOptions], assertOutputs(t, fullOutput, stdout, stderr), withPtty) } func testCommandOutput(t *testing.T, withOptions func(*options.TerragruntOptions), assertResults func(string, *util.CmdOutput), allocateStdout bool) { t.Helper() terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) // Specify a single (locking) buffer for both as a way to check that the output is being written in the correct // order var allOutputBuffer BufferWithLocking terragruntOptions.Writers.Writer = &allOutputBuffer terragruntOptions.Writers.ErrWriter = &allOutputBuffer terragruntOptions.TerraformCliArgs.AppendArgument("same") withOptions(terragruntOptions) l := logger.CreateLogger() out, err := shell.RunCommandWithOutput(t.Context(), l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "", !allocateStdout, false, "testdata/test_outputs.sh", "same") assert.NotNil(t, out, "Should get output") require.NoError(t, err, "Should have no error") assert.NotNil(t, out, "Should get output") assertResults(allOutputBuffer.String(), out) } func assertOutputs( t *testing.T, expectedAllOutputs []string, expectedStdOutputs []string, expectedStdErrs []string, ) func(string, *util.CmdOutput) { t.Helper() return func(allOutput string, out *util.CmdOutput) { allOutputs := strings.Split(strings.TrimSpace(allOutput), "\n") assert.Len(t, allOutputs, len(expectedAllOutputs)) for i := range allOutputs { assert.Contains(t, allOutputs[i], expectedAllOutputs[i], allOutputs[i]) } stdOutputs := strings.Split(strings.TrimSpace(out.Stdout.String()), "\n") assert.Equal(t, expectedStdOutputs, stdOutputs) stdErrs := strings.Split(strings.TrimSpace(out.Stderr.String()), "\n") assert.Equal(t, expectedStdErrs, stdErrs) } } // A goroutine-safe bytes.Buffer type BufferWithLocking struct { buffer bytes.Buffer mutex sync.Mutex } // Write appends the contents of p to the buffer, growing the buffer as needed. It returns // the number of bytes written. func (s *BufferWithLocking) Write(p []byte) (n int, err error) { s.mutex.Lock() defer s.mutex.Unlock() return s.buffer.Write(p) } // String returns the contents of the unread portion of the buffer // as a string. If the Buffer is a nil pointer, it returns "". func (s *BufferWithLocking) String() string { s.mutex.Lock() defer s.mutex.Unlock() return s.buffer.String() } ================================================ FILE: internal/shell/run_cmd_test.go ================================================ package shell_test import ( "bytes" "testing" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/pkg/options" ) func TestRunShellCommand(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) l := logger.CreateLogger() cmd := shell.RunCommand(t.Context(), l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") require.NoError(t, cmd) cmd = shell.RunCommand(t.Context(), l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "not-a-real-command") require.Error(t, cmd) } func TestRunShellOutputToStderrAndStdout(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) terragruntOptions.TerraformCliArgs.AppendFlag("--version") terragruntOptions.Writers.Writer = stdout terragruntOptions.Writers.ErrWriter = stderr l := logger.CreateLogger() cmd := shell.RunCommand(t.Context(), l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") require.NoError(t, cmd) assert.Contains(t, stdout.String(), "OpenTofu", "Output directed to stdout") assert.Empty(t, stderr.String(), "No output to stderr") stdout = new(bytes.Buffer) stderr = new(bytes.Buffer) terragruntOptions.TerraformCliArgs = iacargs.New() terragruntOptions.Writers.Writer = stderr terragruntOptions.Writers.ErrWriter = stderr cmd = shell.RunCommand(t.Context(), l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") require.NoError(t, cmd) assert.Contains(t, stderr.String(), "OpenTofu", "Output directed to stderr") assert.Empty(t, stdout.String(), "No output to stdout") } func TestLastReleaseTag(t *testing.T) { t.Parallel() var tags = []string{ "refs/tags/v0.0.1", "refs/tags/v0.0.2", "refs/tags/v0.10.0", "refs/tags/v20.0.1", "refs/tags/v0.3.1", "refs/tags/v20.1.2", "refs/tags/v0.5.1", } lastTag := shell.LastReleaseTag(tags) assert.NotEmpty(t, lastTag) assert.Equal(t, "v20.1.2", lastTag) } func TestGitLevelTopDirCaching(t *testing.T) { t.Parallel() ctx := t.Context() ctx = cache.ContextWithCache(ctx) c := cache.ContextCache[string](ctx, cache.RunCmdCacheContextKey) assert.NotNil(t, c) assert.Empty(t, c.Cache) terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) l := logger.CreateLogger() path := "." path1, err := shell.GitTopLevelDir(ctx, l, terragruntOptions.Env, path) require.NoError(t, err) path2, err := shell.GitTopLevelDir(ctx, l, terragruntOptions.Env, path) require.NoError(t, err) assert.Equal(t, path1, path2) assert.Len(t, c.Cache, 1) } ================================================ FILE: internal/shell/run_cmd_unix_test.go ================================================ //go:build linux || darwin // +build linux darwin package shell_test import ( "context" "fmt" "strconv" "syscall" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/stretchr/testify/require" ) func TestRunCommandWithOutputInterrupt(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) l := logger.CreateLogger() errCh := make(chan error) expectedWait := 5 ctx, cancel := context.WithCancelCause(t.Context()) cmdPath := "testdata/test_sigint_wait.sh" go func() { _, err := shell.RunCommandWithOutput(ctx, l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "", false, false, cmdPath, strconv.Itoa(expectedWait)) errCh <- err }() time.AfterFunc(3*time.Second, func() { cancel(signal.NewContextCanceledError(syscall.SIGINT)) }) actualErr := <-errCh require.Error(t, actualErr, "Expected an error but got none") // The process might either exit with the expected status code or be killed by a signal // depending on timing and system conditions expectedExitStatusErr := fmt.Sprintf("Failed to execute \"%s 5\" in .\n\nexit status %d", cmdPath, expectedWait) expectedKilledErr := fmt.Sprintf("Failed to execute \"%s 5\" in .\n\nsignal: killed", cmdPath) if actualErr.Error() == expectedKilledErr { t.Errorf("Expected process to gracefully terminate but got\n: %s", actualErr.Error()) } else if actualErr.Error() != expectedExitStatusErr { t.Errorf("Expected error to be:\n %s\nbut got:\n %s", expectedExitStatusErr, actualErr.Error()) } } ================================================ FILE: internal/shell/run_cmd_windows_test.go ================================================ //go:build windows // +build windows package shell_test import ( "context" "fmt" "os" "strconv" "strings" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/os/signal" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWindowsRunCommandWithOutputInterrupt(t *testing.T) { t.Parallel() terragruntOptions, err := options.NewTerragruntOptionsForTest("") assert.Nil(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) l := logger.CreateLogger() errCh := make(chan error) expectedWait := 5 ctx, cancel := context.WithCancelCause(t.Context()) cmdPath := "testdata\\test_sigint_wait.bat" go func() { _, err := shell.RunCommandWithOutput(ctx, l, configbridge.ShellRunOptsFromOpts(terragruntOptions), "", false, false, cmdPath, strconv.Itoa(expectedWait)) errCh <- err }() time.AfterFunc(3*time.Second, func() { cancel(signal.NewContextCanceledError(os.Kill)) }) actualErr := <-errCh require.Error(t, actualErr, "Expected an error but got none") // The process might either exit with the expected status code or be killed by a signal // depending on timing and system conditions. On Windows, the error message might also // include stderr output from the batch file execution. // Check if the error contains the expected patterns rather than exact matches // since Windows batch files might include additional stderr output actualErrStr := actualErr.Error() containsExitStatus5 := strings.Contains(actualErrStr, "exit status 5") containsExitStatus1 := strings.Contains(actualErrStr, "exit status 1") containsKilled := strings.Contains(actualErrStr, "signal: killed") containsFailedExecute := strings.Contains(actualErrStr, fmt.Sprintf("Failed to execute \"%s", cmdPath)) if containsKilled { t.Errorf("Expected process to gracefully terminate but got\n: %s", actualErrStr) } // On Windows, the batch file might exit with status 1 when interrupted, or be killed by signal if !containsFailedExecute || (!containsExitStatus5 && !containsExitStatus1) { t.Errorf("Expected error to contain 'Failed to execute \"%s' and either 'exit status 5', or 'exit status 1', but got:\n %s", cmdPath, actualErrStr) } } ================================================ FILE: internal/shell/testdata/test_outputs.sh ================================================ #!/usr/bin/env bash echo 'stdout1' sleep 1 >&2 echo 'stderr1' sleep 1 echo 'stdout2' sleep 1 >&2 echo 'stderr2' sleep 1 >&2 echo 'stderr3' ================================================ FILE: internal/shell/testdata/test_sigint_wait.bat ================================================ @echo off set wait_time=%1 rem Simple infinite loop that can be interrupted :loop rem Use ping to create a 1-second delay (ping localhost -n 2 creates ~1 second delay) ping -n 2 127.0.0.1 >nul 2>&1 goto loop ================================================ FILE: internal/shell/testdata/test_sigint_wait.sh ================================================ #!/usr/bin/env bash set -e WAIT_TIME=$1 trap int_handler INT function int_handler() { sleep "$WAIT_TIME" exit "$WAIT_TIME" } while true; do sleep 0.1; done ================================================ FILE: internal/stacks/clean/clean.go ================================================ // Package clean provides the logic for cleaning up stack configurations. package clean import ( "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // CleanStacks removes stack directories within the specified working directory, unless the command is "destroy". // It returns an error if any issues occur during the deletion process, or nil if successful. func CleanStacks(l log.Logger, opts *options.TerragruntOptions) error { if opts.TerraformCommand == tf.CommandNameDestroy { l.Debugf("Skipping stack clean for %s, as part of delete command", opts.WorkingDir) return nil } errs := &errors.MultiError{} walkFn := func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { l.Warnf("Error accessing path %s: %v", path, walkErr) errs = errs.Append(walkErr) return nil } if d.IsDir() && d.Name() == ".terragrunt-stack" { relPath, relErr := filepath.Rel(opts.WorkingDir, path) if relErr != nil { relPath = path // fallback to absolute if error } l.Infof("Deleting stack directory: %s", relPath) if rmErr := os.RemoveAll(path); rmErr != nil { l.Errorf("Failed to delete stack directory %s: %v", relPath, rmErr) errs = errs.Append(rmErr) } return filepath.SkipDir } return nil } if walkErr := filepath.WalkDir(opts.WorkingDir, walkFn); walkErr != nil { errs = errs.Append(walkErr) } return errs.ErrorOrNil() } ================================================ FILE: internal/stacks/generate/generate.go ================================================ // Package generate provides functionality for generating stacks from stack files. package generate import ( "context" "path/filepath" "runtime" "slices" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/worker" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "golang.org/x/sync/errgroup" ) // StackNode represents a stack file in the file system. // The parent is the node that generates the current node, // and children are the nodes that are generated by the current node. type StackNode struct { Parent *StackNode FilePath string Children []*StackNode Level int } // NewStackNode creates a new stack node. func NewStackNode(filePath string) *StackNode { return &StackNode{ FilePath: filePath, Level: -1, Children: make([]*StackNode, 0), } } // GenerateStacks generates the stack files using topological ordering to prevent race conditions. // Stack files are generated level by level, ensuring parent stacks complete before their children. // Worktrees must be provided by the caller if needed; this function will never create worktrees internally. func GenerateStacks( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, wts *worktrees.Worktrees, ) error { foundFiles, err := ListStackFiles(ctx, l, opts, opts.WorkingDir, wts) if err != nil { return errors.Errorf("Failed to list stack files in %s %w", opts.WorkingDir, err) } if len(foundFiles) == 0 { if opts.StackAction == "generate" { l.Warnf("No stack files found in %s Nothing to generate.", opts.WorkingDir) } return nil } generatedFiles := make(map[string]bool) stackTrees := BuildStackTopology(l, foundFiles, opts.WorkingDir) const maxLevel = 1024 for level := range maxLevel { if level == maxLevel-1 { return errors.Errorf("Cycle detected: maximum level (%d) exceeded", maxLevel) } levelNodes := getNodesAtLevel(stackTrees, level) if len(levelNodes) == 0 { break } if err := generateLevel(ctx, l, opts, level, levelNodes, generatedFiles); err != nil { return err } if err := discoverAndAddNewNodes(ctx, l, opts, wts, stackTrees, generatedFiles, level+1); err != nil { return err } } return nil } // generateLevel handles the concurrent generation of all stack files at a given level. func generateLevel(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, level int, levelNodes []*StackNode, generatedFiles map[string]bool) error { l.Debugf("Generating stack level %d with %d files", level, len(levelNodes)) wp := worker.NewWorkerPool(opts.Parallelism) defer wp.Stop() for _, node := range levelNodes { if generatedFiles[node.FilePath] { continue } generatedFiles[node.FilePath] = true // Before attempting to generate the stack file, we need to double-check that the file exists. // Generation at a higher level might have resulted in this file being removed. if !util.FileExists(node.FilePath) { continue } wp.Submit(func() error { _, pctx := configbridge.NewParsingContext(ctx, l, opts) return config.GenerateStackFile(ctx, l, pctx, wp, node.FilePath) }) } return wp.Wait() } // discoverAndAddNewNodes discovers new stack files and adds them to the dependency graph. func discoverAndAddNewNodes( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, worktrees *worktrees.Worktrees, dependencyGraph map[string]*StackNode, generatedFiles map[string]bool, minLevel int, ) error { newFiles, listErr := ListStackFiles(ctx, l, opts, opts.WorkingDir, worktrees) if listErr != nil { return errors.Errorf("Failed to list stack files after level %d: %w", minLevel-1, listErr) } addNewNodesToGraph(l, dependencyGraph, newFiles, generatedFiles, opts.WorkingDir) return nil } // BuildStackTopology creates a topological tree based on directory hierarchy. func BuildStackTopology(l log.Logger, stackFiles []string, workingDir string) map[string]*StackNode { nodes := make(map[string]*StackNode) for _, file := range stackFiles { nodes[file] = NewStackNode(file) } for _, node := range nodes { assignNodeLevel(l, node, nodes, workingDir) } return nodes } // assignNodeLevel recursively assigns levels to nodes based on directory depth. func assignNodeLevel(l log.Logger, node *StackNode, allNodes map[string]*StackNode, workingDir string) int { if node.Level != -1 { return node.Level } nodeDir := filepath.Dir(node.FilePath) parentPath := findParentStackFile(nodeDir, allNodes, workingDir) if parentPath == "" { node.Level = 0 return node.Level } parent := allNodes[parentPath] if parent == nil { node.Level = 0 return node.Level } parentLevel := assignNodeLevel(l, parent, allNodes, workingDir) node.Level = parentLevel + 1 node.Parent = parent parent.Children = append(parent.Children, node) l.Debugf("Stack %s (level %d) is child of %s (level %d)", node.FilePath, node.Level, parent.FilePath, parent.Level) return node.Level } // findParentStackFile finds the parent stack file for a given directory. func findParentStackFile(childDir string, allNodes map[string]*StackNode, workingDir string) string { currentDir := childDir for { parentDir := filepath.Dir(currentDir) if parentDir == currentDir { break } if parentDir == workingDir { potentialParent := filepath.Join(workingDir, config.DefaultStackFile) if _, exists := allNodes[potentialParent]; exists { return potentialParent } break } potentialParent := filepath.Join(parentDir, config.DefaultStackFile) if _, exists := allNodes[potentialParent]; exists { return potentialParent } currentDir = parentDir } return "" } // getNodesAtLevel returns all nodes at a specific level. func getNodesAtLevel(nodes map[string]*StackNode, level int) []*StackNode { var levelNodes []*StackNode for _, node := range nodes { if node.Level == level { levelNodes = append(levelNodes, node) } } return levelNodes } // addNewNodesToGraph adds newly discovered stack files to the dependency graph. func addNewNodesToGraph( l log.Logger, existingNodes map[string]*StackNode, allFiles []string, generatedFiles map[string]bool, workingDir string, ) { newFiles := make([]string, 0) for _, file := range allFiles { if _, exists := existingNodes[file]; !exists && !generatedFiles[file] { newFiles = append(newFiles, file) } } if len(newFiles) == 0 { return } l.Debugf("Adding %d new stack files to topology graph", len(newFiles)) for _, file := range newFiles { existingNodes[file] = NewStackNode(file) } for _, file := range newFiles { node := existingNodes[file] assignNodeLevel(l, node, existingNodes, workingDir) } } // ListStackFiles searches for stack files in the specified directory. // // We only want to use the discovery package when the filter flag experiment is enabled, as we need to filter discovery // results to ensure that we get the right files back for generation. func ListStackFiles( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, dir string, worktrees *worktrees.Worktrees, ) ([]string, error) { discovery, err := discovery.NewForStackGenerate(l, discovery.StackGenerateOptions{ WorkingDir: opts.WorkingDir, Filters: opts.Filters, Experiments: opts.Experiments, }) if err != nil { return nil, errors.Errorf("Failed to create discovery for stack generate: %w", err) } discoveredComponents, err := discovery.Discover(ctx, l, opts) if err != nil { return nil, errors.Errorf("Failed to discover stack files: %w", err) } worktreeStacks, err := worktreeStacksToGenerate(ctx, l, opts, worktrees, opts.Experiments) if err != nil { return nil, errors.Errorf("Failed to get worktree stacks to generate: %w", err) } foundFiles := make([]string, 0, len(discoveredComponents)+len(worktreeStacks)) for _, c := range discoveredComponents { if _, ok := c.(*component.Stack); !ok { continue } foundFiles = append(foundFiles, filepath.Join(c.Path(), config.DefaultStackFile)) } for _, c := range worktreeStacks { foundFiles = append(foundFiles, filepath.Join(c.Path(), config.DefaultStackFile)) } return foundFiles, nil } // worktreeStacksToGenerate returns a slice of stacks that need to be generated from the worktree stacks. func worktreeStacksToGenerate( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, w *worktrees.Worktrees, experiments experiment.Experiments, ) (component.Components, error) { // If worktrees is nil, there are no worktrees to process, return empty components. if w == nil { return component.Components{}, nil } stacksToGenerate := component.NewThreadSafeComponents(component.Components{}) // If we edit a stack in a worktree, we need to generate it, at the minimum. stackDiff := w.Stacks() editedStacks := slices.Concat( stackDiff.Added, stackDiff.Removed, ) for _, changed := range stackDiff.Changed { editedStacks = append(editedStacks, changed.FromStack, changed.ToStack) } for _, stack := range editedStacks { stacksToGenerate.EnsureComponent(stack) } // When the expanded filter for a given Git expression requires parsing, // we need to generate all the stacks in the given worktree, as units within the generated stack might be affected. // // Based on business logic, the from branch here should never be used, but we'll check it anyways for completeness. // We only require parsing for reading filters, and those only trigger in expanded Git expressions when // the file is modified (which would result in a toFilter being returned). fullDiscoveries := map[string]*discovery.Discovery{} for _, pair := range w.WorktreePairs { fromFilters, toFilters, err := pair.Expand() if err != nil { return nil, errors.Errorf("failed to expand worktree pair: %w", err) } if _, requiresParse := fromFilters.RequiresParse(); requiresParse { disc, err := discovery.NewForStackGenerate(l, discovery.StackGenerateOptions{ WorkingDir: pair.FromWorktree.Path, Filters: stackTypeFilter(), Experiments: experiments, }) if err != nil { return nil, errors.Errorf("Failed to create discovery for worktree %s: %w", pair.FromWorktree.Ref, err) } fullDiscoveries[pair.FromWorktree.Ref] = disc } if _, requiresParse := toFilters.RequiresParse(); requiresParse { disc, err := discovery.NewForStackGenerate(l, discovery.StackGenerateOptions{ WorkingDir: pair.ToWorktree.Path, Filters: stackTypeFilter(), Experiments: experiments, }) if err != nil { return nil, errors.Errorf("Failed to create discovery for worktree %s: %w", pair.ToWorktree.Ref, err) } fullDiscoveries[pair.ToWorktree.Ref] = disc } } g, ctx := errgroup.WithContext(ctx) g.SetLimit(min(runtime.NumCPU(), len(fullDiscoveries))) var ( mu sync.Mutex errs []error ) for ref, disc := range fullDiscoveries { // Create per-iteration local copies to avoid closure capture bug refCopy := ref discCopy := disc g.Go(func() error { components, err := discCopy.Discover(ctx, l, opts) if err != nil { mu.Lock() errs = append(errs, errors.Errorf("Failed to discover stacks in worktree %s: %w", refCopy, err)) mu.Unlock() return nil } for _, c := range components { stacksToGenerate.EnsureComponent(c) } return nil }) } if err := g.Wait(); err != nil { return nil, err } if len(errs) > 0 { return stacksToGenerate.ToComponents(), errors.Join(errs...) } return stacksToGenerate.ToComponents(), nil } // stackTypeFilter returns a filter.Filters that restricts to stack components. func stackTypeFilter() filter.Filters { attrExpr := filter.NewTypeExpression(component.StackKind) return filter.Filters{filter.NewFilter(attrExpr, attrExpr.String())} } ================================================ FILE: internal/stacks/output/output.go ================================================ // Package output provides functionality for collecting and collating the // unit outputs for all units in a stack hierarchy. package output import ( "context" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/stacks/generate" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/zclconf/go-cty/cty" ) // StackOutput collects and returns the OpenTofu/Terraform output values for all declared units in a stack hierarchy. // // This function is a central component of Terragrunt's stack output system, providing a mechanism to // aggregate and organize outputs from multiple deployments in a hierarchical structure. It's particularly // useful when working with complex infrastructure composed of multiple interconnected OpenTofu/Terraform units. // // The function performs several key operations: // // 1. Discovers all stack definition files (terragrunt.stack.hcl) in the working directory and its subdirectories. // 2. For each stack file, parses the configuration and extracts the declared stacks and units. // 3. For each unit, reads its OpenTofu/Terraform outputs from the corresponding directory within .terragrunt-stack. // 4. Constructs a hierarchical map of outputs by organizing units according to their position in the stack hierarchy. // Units are keyed using dot notation that reflects the stack path (e.g., "parent.child.unit"). // 5. Orders stack names from the highest level (shortest path) to deepest nested (longest path). // 6. Nests the flat output map into a hierarchical structure and converts it to a cty.Value object. // // The returned cty.Value object contains a structured representation of all outputs, preserving the // nested relationship between stacks and units. This makes it easy to access outputs from specific // parts of the infrastructure while maintaining awareness of the overall architecture. // // For telemetry and debugging purposes, the function logs various events at the debug level, including // when outputs are added for specific units and stack keys. // // Parameters: // - ctx: Context for the operation, which may include telemetry collection. // - opts: TerragruntOptions containing configuration settings and the working directory path. // // Returns: // - cty.Value: A hierarchical object containing all outputs from the stack units, organized by stack path. // - error: An error if any operation fails during discovery, parsing, output collection, or conversion. // // Errors can occur during stack file listing, value reading, stack config parsing, output reading, // or when converting the final output structure to cty.Value format. func StackOutput( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, ) (cty.Value, error) { l.Debugf("Generating output from %s", opts.WorkingDir) // Create worktrees internally if filter-flag experiment is enabled and git filters are present wts, err := buildWorktreesIfNeeded(ctx, l, opts) if err != nil { return cty.NilVal, errors.Errorf("failed to create worktrees: %w", err) } if wts != nil { defer func() { if cleanupErr := wts.Cleanup(ctx, l); cleanupErr != nil { l.Errorf("failed to cleanup worktrees: %v", cleanupErr) } }() } foundFiles, err := generate.ListStackFiles(ctx, l, opts, opts.WorkingDir, wts) if err != nil { return cty.NilVal, errors.Errorf("Failed to list stack files in %s: %w", opts.WorkingDir, err) } if len(foundFiles) == 0 { l.Warnf("No stack files found in %s Nothing to generate.", opts.WorkingDir) return cty.NilVal, nil } outputs := make(map[string]map[string]cty.Value) declaredStacks := make(map[string]string) declaredUnits := make(map[string]*config.Unit) // save parsed stacks parsedStackFiles := make(map[string]*config.StackConfig, len(foundFiles)) for _, path := range foundFiles { dir := filepath.Dir(path) ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) values, valuesErr := config.ReadValues(ctx, pctx, l, dir) if valuesErr != nil { return cty.NilVal, errors.Errorf("Failed to read values from %s: %w", dir, valuesErr) } stackFile, stackErr := config.ReadStackConfigFile(ctx, l, pctx, path, values) if stackErr != nil { return cty.NilVal, errors.Errorf("Failed to read stack file %s: %w", path, stackErr) } parsedStackFiles[path] = stackFile targetDir := filepath.Join(dir, config.StackDir) for _, stack := range stackFile.Stacks { declaredStacks[filepath.Join(targetDir, stack.Path)] = stack.Name l.Debugf("Registered stack %s at path %s", stack.Name, filepath.Join(targetDir, stack.Path)) } for _, unit := range stackFile.Units { unitDir := config.GetUnitDir(dir, unit) var output map[string]cty.Value telemetryErr := telemetry.TelemeterFromContext(ctx).Collect(ctx, "unit_output", map[string]any{ "unit_name": unit.Name, "unit_source": unit.Source, "unit_path": unit.Path, }, func(ctx context.Context) error { var outputErr error output, outputErr = unit.ReadOutputs(ctx, l, pctx, unitDir) return outputErr }) if telemetryErr != nil { return cty.NilVal, errors.New(telemetryErr) } key := filepath.Join(targetDir, unit.Path) declaredUnits[key] = unit outputs[key] = output l.Debugf("Added output for %s", key) } } unitOutputs := make(map[string]map[string]cty.Value) // Build stack list separated by stacks, find all nested stacks, and build a dotted path. If no stack is found, use the unit name. for path, unit := range declaredUnits { output, found := outputs[path] if !found { l.Debugf("No output found for %s", path) continue } // Implement more logic to find all stacks in which the path is located stackNames := []string{} nameToPath := make(map[string]string) // Map to track which path each stack name came from for stackPath, stackName := range declaredStacks { if strings.Contains(path, stackPath) { stackNames = append(stackNames, stackName) nameToPath[stackName] = stackPath } } // Sort stackNames based on the length of stackPath to ensure correct order stackNamesSorted := make([]string, len(stackNames)) copy(stackNamesSorted, stackNames) for i := range stackNamesSorted { for j := i + 1; j < len(stackNamesSorted); j++ { // Compare lengths of the actual paths from the nameToPath map, not the declaredStacks lookup if len(nameToPath[stackNamesSorted[i]]) < len(nameToPath[stackNamesSorted[j]]) { stackNamesSorted[i], stackNamesSorted[j] = stackNamesSorted[j], stackNamesSorted[i] } } } stackKey := unit.Name if len(stackNamesSorted) > 0 { stackKey = strings.Join(stackNamesSorted, ".") + "." + unit.Name } unitOutputs[stackKey] = output l.Debugf("Added output for stack key %s", stackKey) } // Convert finalMap into a cty.ObjectVal result := make(map[string]cty.Value) nestedOutputs, err := nestUnitOutputs(unitOutputs) if err != nil { return cty.NilVal, errors.Errorf("Failed to nest unit outputs: %w", err) } ctyResult, err := config.GoTypeToCty(nestedOutputs) if err != nil { return cty.NilVal, errors.Errorf("Failed to convert unit output to cty value: %s %w", result, err) } return ctyResult, nil } // nestUnitOutputs transforms a flat map of unit outputs into a nested hierarchical structure. // // This function is a critical part of Terragrunt/Opentofu's stack output system, converting flat key-value pairs // with dot notation into a proper nested object hierarchy. It processes each flattened key (e.g., "parent.child.unit") // by splitting it into path segments and recursively building the corresponding nested structure. // // The algorithm works as follows: // 1. For each entry in the flat map, split its key by dots to get the path segments // 2. Iteratively traverse the nested structure, creating intermediate maps as needed // 3. When reaching the final path segment, convert the map of cty.Values to a Go interface{} // representation and store it at that location // 4. Continue until all flat entries have been properly nested // // This approach preserves the hierarchical relationship between stacks and units while making // the data structure easier to navigate and query programmatically. // // Parameters: // - flat: A map where keys are dot-separated paths (e.g., "parent.child.unit") and values are // maps of cty.Value representing the OpenTofu/Terraform outputs for each unit // // Returns: // - map[string]any: A nested map structure reflecting the hierarchy implied by the dot notation // - error: An error if conversion fails, particularly when building the nested structure // // Errors can occur during cty.Value conversion or when attempting to traverse the nested structure // if the path contains contradictory type information (e.g., a path segment is both a leaf and a branch). func nestUnitOutputs(flat map[string]map[string]cty.Value) (map[string]any, error) { nested := make(map[string]any) for flatKey, value := range flat { parts := strings.Split(flatKey, ".") current := nested for i, part := range parts { if i == len(parts)-1 { ctyValue, err := config.ConvertValuesMapToCtyVal(value) if err != nil { return nil, errors.Errorf("Failed to convert unit output to cty value: %s %w", flatKey, err) } current[part] = ctyValue } else { if _, exists := current[part]; !exists { // Traverse or create next level current[part] = make(map[string]any) } var ok bool current, ok = current[part].(map[string]any) if !ok { return nil, errors.Errorf("Failed to traverse unit output: %v %s", flat, part) } } } } return nested, nil } // buildWorktreesIfNeeded creates worktrees if the filter-flag experiment is enabled and git filters exist. // Returns nil worktrees if the experiment is not enabled or no git filters are present. func buildWorktreesIfNeeded( ctx context.Context, l log.Logger, opts *options.TerragruntOptions, ) (*worktrees.Worktrees, error) { gitFilters := opts.Filters.UniqueGitFilters() if len(gitFilters) == 0 { return nil, nil } return worktrees.NewWorktrees(ctx, l, opts.WorkingDir, gitFilters) } ================================================ FILE: internal/strict/category.go ================================================ package strict import ( "slices" "sort" ) // Categories is multiple of DeprecatedFlag Category. type Categories []*Category // FilterByNames filters `categories` by the given `names`. func (categories Categories) FilterByNames(names ...string) Categories { var filtered Categories for _, category := range categories { if slices.Contains(names, category.Name) { filtered = append(filtered, category) } } return filtered } // FilterNotHidden filters `categories` by the `Hidden:false` field. func (categories Categories) FilterNotHidden() Categories { var filtered Categories for _, category := range categories { if !category.Hidden { filtered = append(filtered, category) } } return filtered } // Find search category by given `name`, returns nil if not found. func (categories Categories) Find(name string) *Category { for _, category := range categories { if category.Name == name { return category } } return nil } // Len implements `sort.Interface` interface. func (categories Categories) Len() int { return len(categories) } // Less implements `sort.Interface` interface. func (categories Categories) Less(i, j int) bool { // Handle empty names: empty names should come last if categories[i].Name == "" { return false } if categories[j].Name == "" { return true } // Normal lexicographical comparison return categories[i].Name < categories[j].Name } // Swap implements `sort.Interface` interface. func (categories Categories) Swap(i, j int) { (categories)[i], (categories)[j] = (categories)[j], (categories)[i] } // Sort returns `categories` in sorted order by `Name`. func (categories Categories) Sort() Categories { sort.Sort(categories) return categories } // Category represents a strict control category. Used to group controls when they are displayed. type Category struct { // Name is the name of the category. Name string // Hidden specifies whether controls belonging to this category should be displayed. Hidden bool } // String implements `fmt.Stringer` interface. func (category *Category) String() string { return category.Name } ================================================ FILE: internal/strict/control.go ================================================ package strict import ( "context" "slices" "sort" "strings" "github.com/gruntwork-io/terragrunt/pkg/log" ) const CompletedControlsFmt = "The following strict control(s) are already completed: %s. Please remove any completed strict controls, as setting them no longer does anything. For a list of all ongoing strict controls, and the outcomes of previous strict controls, see https://docs.terragrunt.com/reference/strict-mode or get the actual list by running the `terragrunt info strict` command." type ControlNames []string func (names ControlNames) String() string { return strings.Join(names, ", ") } // Control represents an interface that can be enabled or disabled in strict mode. // When the Control is Enabled, Terragrunt will behave in a way that is not backwards compatible. type Control interface { // GetName returns the name of the strict control. GetName() string // GetDescription returns the description of the strict control. GetDescription() string // GetStatus returns the status of the strict control. GetStatus() Status // Enable enables the control. Enable() // GetEnabled returns true if the control is enabled. GetEnabled() bool // GetCategory returns category of the strict control. GetCategory() *Category // SetCategory sets the category. SetCategory(category *Category) // GetSubcontrols returns all subcontrols. GetSubcontrols() Controls // AddSubcontrols adds the given `newCtrls` as subcontrols. AddSubcontrols(newCtrls ...Control) // SuppressWarning suppresses the warning message from being displayed. SuppressWarning() // Evaluate evaluates the strict control. Evaluate(ctx context.Context) error } // Controls are multiple of Controls. type Controls []Control // Names returns names of all `ctrls`. func (ctrls Controls) Names() ControlNames { var names ControlNames for _, ctrl := range ctrls { if name := ctrl.GetName(); name != "" { names = append(names, name) } } slices.Sort(names) return names } // SuppressWarning suppresses the warning message from being displayed. func (ctrls Controls) SuppressWarning() Controls { for _, ctrl := range ctrls { ctrl.SuppressWarning() } return ctrls } // FilterByStatus filters `ctrls` by given statuses. func (ctrls Controls) FilterByStatus(statuses ...Status) Controls { var filtered Controls for _, ctrl := range ctrls { if slices.Contains(statuses, ctrl.GetStatus()) { filtered = append(filtered, ctrl) } } return filtered } // RemoveDuplicates removes controls with duplicate names. func (ctrls Controls) RemoveDuplicates() Controls { var unique Controls for _, ctrl := range ctrls { skip := false for _, uniqueCtrl := range unique { if uniqueCtrl.GetName() == ctrl.GetName() && uniqueCtrl.GetCategory().Name == ctrl.GetCategory().Name { skip = true break } } if !skip { unique = append(unique, ctrl) } } return unique } // FilterByEnabled filters `ctrls` by `Enabled: true` field. func (ctrls Controls) FilterByEnabled() Controls { var filtered Controls for _, ctrl := range ctrls { if ctrl.GetEnabled() { filtered = append(filtered, ctrl) } } return filtered } // FilterByNames filters `ctrls` by the given `names`. func (ctrls Controls) FilterByNames(names ...string) Controls { var filtered Controls for _, ctrl := range ctrls { if slices.Contains(names, ctrl.GetName()) { filtered = append(filtered, ctrl) } } return filtered } // FilterByCategories filters `ctrls` by the given `categories`. func (ctrls Controls) FilterByCategories(categories ...*Category) Controls { var filtered Controls for _, ctrl := range ctrls { if category := ctrl.GetCategory(); (category == nil && len(categories) == 0) || (category != nil && slices.Contains(categories, category)) { filtered = append(filtered, ctrl) } } return filtered } // GetCategories returns a unique list of the `ctrls` categories. func (ctrls Controls) GetCategories() Categories { var categories Categories for _, ctrl := range ctrls { if category := ctrl.GetCategory(); category != nil && !slices.Contains(categories, category) { categories = append(categories, ctrl.GetCategory()) } } return categories } // SetCategory sets the given category for all `ctrls`. func (ctrls Controls) SetCategory(category *Category) { for _, ctrl := range ctrls { ctrl.SetCategory(category) } } // Enable recursively enables all `ctrls`. func (ctrls Controls) Enable() { for _, ctrl := range ctrls { ctrl.Enable() ctrl.GetSubcontrols().Enable() } } // EnableControl validates that the specified control name is valid and enables `ctrl`. func (ctrls Controls) EnableControl(name string) error { if ctrl := ctrls.Find(name); ctrl != nil { ctrl.Enable() ctrl.GetSubcontrols().Enable() return nil } return NewInvalidControlNameError(ctrls.FilterByStatus(ActiveStatus).Names()) } // LogEnabled logs the control names that are enabled. func (ctrls Controls) LogEnabled(logger log.Logger) { enabledControls := ctrls.FilterByEnabled() if len(enabledControls) > 0 { logger.Debugf("Enabled strict control(s): %s", enabledControls.Names()) } } // LogCompletedControls warns about any completed controls from the given explicitly requested names. func (ctrls Controls) LogCompletedControls(logger log.Logger, requestedNames []string) { completedControls := ctrls.FilterByNames(requestedNames...).FilterByStatus(CompletedStatus) if len(completedControls) > 0 { logger.Warnf(CompletedControlsFmt, completedControls.Names().String()) } } // Evaluate returns an error if the one of the controls is enabled otherwise logs warning messages and returns nil. func (ctrls Controls) Evaluate(ctx context.Context) error { for _, ctrl := range ctrls { if err := ctrl.Evaluate(ctx); err != nil { return err } } return nil } // AddSubcontrols adds the given `newCtrls` as subcontrols into all `ctrls`. func (ctrls Controls) AddSubcontrols(newCtrls ...Control) { for _, ctrl := range ctrls { ctrl.AddSubcontrols(newCtrls...) } } // GetSubcontrols returns all subcontrols from all `ctrls`. func (ctrls Controls) GetSubcontrols() Controls { found := make(Controls, 0, len(ctrls)) for _, ctrl := range ctrls { found = append(found, ctrl.GetSubcontrols()...) } return found } func (ctrls Controls) AddSubcontrolsToCategory(categoryName string, controls ...Control) { for _, ctrl := range ctrls { category := ctrl.GetSubcontrols().GetCategories().Find(categoryName) if category == nil { category = &Category{Name: categoryName} } Controls(controls).SetCategory(category) ctrl.AddSubcontrols(controls...) } } // Find search control by given `name`, returns nil if not found. func (ctrls Controls) Find(name string) Control { for _, ctrl := range ctrls { if ctrl != nil && ctrl.GetName() == name { return ctrl } } return nil } // Len implements `sort.Interface` interface. func (ctrls Controls) Len() int { return len(ctrls) } // Less implements `sort.Interface` interface. func (ctrls Controls) Less(i, j int) bool { if len((ctrls)[j].GetName()) == 0 { return false } else if len((ctrls)[i].GetName()) == 0 { return true } if (ctrls)[i].GetStatus() == (ctrls)[j].GetStatus() { return (ctrls)[i].GetName() < (ctrls)[j].GetName() } return (ctrls)[i].GetStatus() < (ctrls)[j].GetStatus() } // Swap implements `sort.Interface` interface. func (ctrls Controls) Swap(i, j int) { (ctrls)[i], (ctrls)[j] = (ctrls)[j], (ctrls)[i] } // Sort returns `ctrls` in sorted order by `Name` and `Status`. func (ctrls Controls) Sort() Controls { sort.Sort(ctrls) return ctrls } ================================================ FILE: internal/strict/control_test.go ================================================ package strict_test // Add some basic tests that confirm that by default, a warning is emitted when strict mode is disabled, // and an error is emitted when a specific control is enabled. // Make sure to test both when the specific control is enabled, and when the global strict mode is enabled. import ( "bytes" "fmt" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testParentAName = "test-parent-a" testOngoingAName = "test-ongoing-a" testOngoingSubAName = "test-ongoing-sub-a" testOngoingBName = "test-ongoing-b" testOngoingCName = "test-ongoing-c" testCompletedAName = "test-completed-a" testCompletedBName = "test-completed-b" testCompletedCName = "test-completed-c" ) var ( testOngoingSubA = func() *controls.Control { return &controls.Control{ Name: testOngoingSubAName, Error: errors.New("a error ongoing"), Warning: "sub a warning ongoing", } } testParentA = func() *controls.Control { return &controls.Control{ Name: testParentAName, Subcontrols: strict.Controls{ testOngoingSubA(), }, } } testOngoingA = func() *controls.Control { return &controls.Control{ Name: testOngoingAName, Subcontrols: strict.Controls{ testOngoingSubA(), }, Error: errors.New("a error ongoing"), Warning: "a warning ongoing", } } testOngoingB = func() *controls.Control { return &controls.Control{ Name: testOngoingBName, Error: errors.New("error ongoing b"), Warning: "warning ongoing b", } } testOngoingC = func() *controls.Control { return &controls.Control{ Name: testOngoingCName, Error: errors.New("error ongoing c"), Warning: "warning ongoing c", } } testCompletedA = func() *controls.Control { return &controls.Control{ Name: testCompletedAName, Status: strict.CompletedStatus, Error: errors.New("no matter"), Warning: "no matter", } } testCompletedB = func() *controls.Control { return &controls.Control{ Name: testCompletedBName, Status: strict.CompletedStatus, Error: errors.New("no matter"), Warning: "no matter", } } testCompletedC = func() *controls.Control { return &controls.Control{ Name: testCompletedCName, Status: strict.CompletedStatus, Error: errors.New("no matter"), Warning: "no matter", } } ) func newTestLogger() (log.Logger, *bytes.Buffer) { formatter := format.NewFormatter(placeholders.Placeholders{placeholders.Message()}) output := new(bytes.Buffer) logger := log.New(log.WithOutput(output), log.WithLevel(log.InfoLevel), log.WithFormatter(formatter)) return logger, output } func newTestControls() strict.Controls { return strict.Controls{ testParentA(), testOngoingA(), testOngoingB(), testOngoingC(), testCompletedA(), testCompletedB(), testCompletedC(), } } func TestEnableControl(t *testing.T) { t.Parallel() type testEnableControl struct { expectedErr error controlName string } testCases := []struct { expectedCompletedMsg string enableControls []testEnableControl expectedEnabledControls []string }{ { enableControls: []testEnableControl{ { controlName: testOngoingAName, }, { controlName: testOngoingCName, }, { controlName: testCompletedAName, }, { controlName: testCompletedCName, }, { controlName: "invalid", expectedErr: strict.NewInvalidControlNameError([]string{testOngoingAName, testOngoingBName, testOngoingCName, testParentAName}), }, }, expectedEnabledControls: []string{testOngoingAName, testOngoingSubAName, testOngoingCName, testCompletedAName, testCompletedCName}, expectedCompletedMsg: fmt.Sprintf(strict.CompletedControlsFmt, strict.ControlNames([]string{testCompletedAName, testCompletedCName})), }, { enableControls: []testEnableControl{ { controlName: testOngoingBName, }, { controlName: testCompletedBName, }, }, expectedEnabledControls: []string{testOngoingBName, testCompletedBName}, expectedCompletedMsg: fmt.Sprintf(strict.CompletedControlsFmt, strict.ControlNames([]string{testCompletedBName})), }, { enableControls: []testEnableControl{}, expectedEnabledControls: []string{}, }, { enableControls: []testEnableControl{ { controlName: testParentAName, }, }, expectedEnabledControls: []string{testParentAName, testOngoingSubAName}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() controls := newTestControls() for _, testEnableControl := range tc.enableControls { err := controls.EnableControl(testEnableControl.controlName) if testEnableControl.expectedErr != nil { require.EqualError(t, err, testEnableControl.expectedErr.Error()) continue } require.NoError(t, err) } var actualEnabledControls []string for _, control := range controls { if control.GetEnabled() { actualEnabledControls = append(actualEnabledControls, control.GetName()) } for _, subcontrol := range control.GetSubcontrols() { if subcontrol.GetEnabled() { actualEnabledControls = append(actualEnabledControls, subcontrol.GetName()) } } } assert.ElementsMatch(t, tc.expectedEnabledControls, actualEnabledControls) logger, output := newTestLogger() requestedNames := make([]string, 0, len(tc.enableControls)) for _, ec := range tc.enableControls { if ec.expectedErr == nil { requestedNames = append(requestedNames, ec.controlName) } } controls.LogEnabled(logger) controls.LogCompletedControls(logger, requestedNames) if tc.expectedCompletedMsg == "" { assert.Empty(t, output.String()) return } assert.Contains(t, strings.TrimSpace(output.String()), tc.expectedCompletedMsg) }) } } func TestEnableStrictMode(t *testing.T) { t.Parallel() testCases := []struct { expectedEnabledControls []string enableStrictMode bool }{ { enableStrictMode: true, expectedEnabledControls: []string{testParentAName, testOngoingSubAName, testOngoingAName, testOngoingSubAName, testOngoingBName, testOngoingCName}, }, { enableStrictMode: false, expectedEnabledControls: []string{}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() controls := newTestControls() if tc.enableStrictMode { controls.FilterByStatus(strict.ActiveStatus).Enable() } var actualEnabledControls []string for _, control := range controls { if control.GetEnabled() { actualEnabledControls = append(actualEnabledControls, control.GetName()) } for _, subcontrol := range control.GetSubcontrols() { if subcontrol.GetEnabled() { actualEnabledControls = append(actualEnabledControls, subcontrol.GetName()) } } } assert.ElementsMatch(t, tc.expectedEnabledControls, actualEnabledControls) logger, output := newTestLogger() controls.LogEnabled(logger) assert.Empty(t, output.String()) }) } } // TestLogCompletedControlsWithParentAndCompletedSubcontrol tests that LogCompletedControls // only warns about explicitly requested controls, not implicitly enabled subcontrols. // This is a regression test for https://github.com/gruntwork-io/terragrunt/issues/5293 func TestLogCompletedControlsWithParentAndCompletedSubcontrol(t *testing.T) { t.Parallel() completedSubControl := &controls.Control{ Name: "completed-sub", Status: strict.CompletedStatus, Error: errors.New("completed error"), Warning: "completed warning", } parentControl := &controls.Control{ Name: "parent-control", Subcontrols: strict.Controls{ completedSubControl, }, } testControls := strict.Controls{ parentControl, completedSubControl, } err := testControls.EnableControl("parent-control") require.NoError(t, err) assert.True(t, completedSubControl.GetEnabled(), "subcontrol should be enabled") logger, output := newTestLogger() testControls.LogCompletedControls(logger, []string{"parent-control"}) assert.Empty(t, output.String(), "should not warn about implicitly enabled completed subcontrols") } // TestLogCompletedControlsWithExplicitlyRequestedCompletedControl tests that LogCompletedControls // DOES warn when a completed control is explicitly requested. func TestLogCompletedControlsWithExplicitlyRequestedCompletedControl(t *testing.T) { t.Parallel() completedControl := &controls.Control{ Name: "completed-control", Status: strict.CompletedStatus, Error: errors.New("completed error"), Warning: "completed warning", } testControls := strict.Controls{ completedControl, } err := testControls.EnableControl("completed-control") require.NoError(t, err) logger, output := newTestLogger() testControls.LogCompletedControls(logger, []string{"completed-control"}) assert.Contains( t, output.String(), "completed-control", "should warn about explicitly requested completed control", ) } func TestEvaluateControl(t *testing.T) { t.Parallel() type testEvaluateControl struct { expectedErr error name string } testCases := []struct { enableControls []string evaluateControls []testEvaluateControl expectedWarns []string }{ { enableControls: []string{testOngoingAName, testOngoingBName}, evaluateControls: []testEvaluateControl{ { name: testOngoingAName, expectedErr: testOngoingA().Error, }, }, expectedWarns: []string{""}, }, { enableControls: []string{testOngoingBName}, evaluateControls: []testEvaluateControl{ { name: testOngoingBName, expectedErr: testOngoingB().Error, }, }, expectedWarns: []string{""}, }, { // Testing output warning message once. enableControls: []string{testOngoingBName}, evaluateControls: []testEvaluateControl{ { name: testOngoingAName, }, { name: testOngoingAName, }, }, expectedWarns: []string{testOngoingA().Warning, testOngoingSubA().Warning}, }, { enableControls: []string{testCompletedAName}, evaluateControls: []testEvaluateControl{ { name: testOngoingAName, }, }, expectedWarns: []string{testOngoingA().Warning, testOngoingSubA().Warning}, }, { enableControls: []string{testCompletedAName}, evaluateControls: []testEvaluateControl{ { name: testCompletedAName, }, }, expectedWarns: []string{""}, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() logger, output := newTestLogger() controls := newTestControls() ctx := t.Context() ctx = log.ContextWithLogger(ctx, logger) for _, name := range tc.enableControls { err := controls.EnableControl(name) require.NoError(t, err) } for _, control := range tc.evaluateControls { err := controls.Find(control.name).Evaluate(ctx) if control.expectedErr != nil { require.EqualError(t, err, control.expectedErr.Error()) assert.Empty(t, output.String()) return } require.NoError(t, err) } if len(tc.expectedWarns) == 0 { assert.Empty(t, output.String()) return } actualWarns := strings.Split(strings.TrimSpace(output.String()), "\n") assert.ElementsMatch(t, actualWarns, tc.expectedWarns) }) } } ================================================ FILE: internal/strict/controls/control.go ================================================ package controls import ( "context" "sync" "sync/atomic" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/pkg/log" ) var _ = strict.Control(new(Control)) // Control is the simplest implementation of the `strict.Control` interface. type Control struct { // Error is the Error that will be returned when the Control is Enabled. Error error // Category is the category of the control. Category *strict.Category // Name is the name of the control. Name string // Description is the description of the control. Description string // Warning is a Warning that will be logged when the Control is not Enabled. Warning string // Subcontrols are child controls. Subcontrols strict.Controls // OnceWarn is used to prevent the warning message from being displayed multiple times. OnceWarn sync.Once // Status of the strict Control. Status strict.Status // Enabled indicates whether the control is enabled. Enabled bool // Suppress suppresses the warning message from being displayed. // Uses int32 for atomic operations (0 = false, 1 = true) suppress int32 } // String implements `fmt.Stringer` interface. func (ctrl *Control) String() string { return ctrl.GetName() } // GetName implements `strict.Control` interface. func (ctrl *Control) GetName() string { return ctrl.Name } // GetDescription implements `strict.Control` interface. func (ctrl *Control) GetDescription() string { return ctrl.Description } // GetStatus implements `strict.Control` interface. func (ctrl *Control) GetStatus() strict.Status { return ctrl.Status } // GetEnabled implements `strict.Control` interface. func (ctrl *Control) GetEnabled() bool { return ctrl.Enabled } // GetCategory implements `strict.Control` interface. func (ctrl *Control) GetCategory() *strict.Category { return ctrl.Category } // SetCategory implements `strict.Control` interface. func (ctrl *Control) SetCategory(category *strict.Category) { ctrl.Category = category } // Enable implements `strict.Control` interface. func (ctrl *Control) Enable() { ctrl.Enabled = true } // GetSubcontrols implements `strict.Control` interface. func (ctrl *Control) GetSubcontrols() strict.Controls { return ctrl.Subcontrols } // AddSubcontrols implements `strict.Control` interface. func (ctrl *Control) AddSubcontrols(newCtrls ...strict.Control) { if ctrl.Subcontrols == nil { ctrl.Subcontrols = make([]strict.Control, 0, len(newCtrls)) } ctrl.Subcontrols = append(ctrl.Subcontrols, newCtrls...) } // SuppressWarning suppresses the warning message from being displayed. func (ctrl *Control) SuppressWarning() { atomic.StoreInt32(&ctrl.suppress, 1) } // isSuppressed returns true if warning is suppressed. func (ctrl *Control) isSuppressed() bool { return atomic.LoadInt32(&ctrl.suppress) == 1 } // Evaluate implements `strict.Control` interface. func (ctrl *Control) Evaluate(ctx context.Context) error { if err := ctx.Err(); err != nil { return errors.Errorf("context error during evaluation: %w", err) } if ctrl == nil { return nil } if ctrl.Enabled { if ctrl.Status != strict.ActiveStatus || ctrl.Error == nil { return nil } return ctrl.Error } if logger := log.LoggerFromContext(ctx); logger != nil && ctrl.Warning != "" && !ctrl.isSuppressed() { ctrl.OnceWarn.Do(func() { logger.Warn(ctrl.Warning) }) } if ctrl.Subcontrols == nil { return nil } return ctrl.Subcontrols.Evaluate(ctx) } ================================================ FILE: internal/strict/controls/controls.go ================================================ // Package controls contains strict controls. package controls import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" ) const ( // DeprecatedCommands is the control that prevents the use of deprecated commands. DeprecatedCommands = "deprecated-commands" // DeprecatedFlags is the control that prevents the use of deprecated flag names. DeprecatedFlags = "deprecated-flags" // DeprecatedEnvVars is the control that prevents the use of deprecated env vars. DeprecatedEnvVars = "deprecated-env-vars" // DeprecatedConfigs is the control that prevents the use of deprecated config fields/section/..., anything related to config syntax. DeprecatedConfigs = "deprecated-configs" // LegacyLogs is a control group for legacy log flags that were in use before the log was redesign. LegacyLogs = "legacy-logs" // TerragruntPrefixFlags is a control group for flags that used to have the `terragrunt-` prefix. TerragruntPrefixFlags = "terragrunt-prefix-flags" // TerragruntPrefixEnvVars is a control group for env vars that used to have the `TERRAGRUNT_` prefix. TerragruntPrefixEnvVars = "terragrunt-prefix-env-vars" // DefaultCommands is a control group for TF commands that were used as default commands, // namely without using the parent `run` commands and were not shortcuts commands. DefaultCommands = "default-commands" // RootTerragruntHCL is the control that prevents usage of a `terragrunt.hcl` file as the root of Terragrunt configurations. RootTerragruntHCL = "root-terragrunt-hcl" // SkipDependenciesInputs is the control related to the deprecated dependency inputs feature. // Dependency inputs are now disabled by default for performance. SkipDependenciesInputs = "skip-dependencies-inputs" // RequireExplicitBootstrap is the control that prevents the backend for remote state from being bootstrapped unless the `--backend-bootstrap` flag is specified. RequireExplicitBootstrap = "require-explicit-bootstrap" // CLIRedesign is the control that prevents the use of commands deprecated as part of the CLI Redesign. CLIRedesign = "cli-redesign" // LegacyAll is a control group for the legacy *-all commands. // This control is marked as completed since the commands have been removed. LegacyAll = "legacy-all" // BareInclude is the control that prevents the use of the `include` block without a label. BareInclude = "bare-include" // DoubleStar enables the use of the `**` glob pattern as a way to match files in subdirectories. // and will log a warning when using **/* DoubleStar = "double-star" // QueueExcludeExternal is the control that prevents the use of the deprecated `--queue-exclude-external` flag. QueueExcludeExternal = "queue-exclude-external" // QueueStrictInclude is the control that prevents the use of the deprecated `--queue-strict-include` flag. QueueStrictInclude = "queue-strict-include" // UnitsThatInclude is the control that prevents the use of the deprecated `--units-that-include` flag. UnitsThatInclude = "units-that-include" // DisableCommandValidation is the control that prevents the use of the deprecated `--disable-command-validation` flag. DisableCommandValidation = "disable-command-validation" // NoDestroyDependenciesCheck is the control that prevents the use of the deprecated `--no-destroy-dependencies-check` flag. NoDestroyDependenciesCheck = "no-destroy-dependencies-check" // InternalTFLint is the control that prevents the use of the deprecated embedded version of tflint. InternalTFLint = "legacy-internal-tflint" // DeprecatedHiddenFlag is the control that prevents the use of the deprecated `--hidden` flag. DeprecatedHiddenFlag = "deprecated-hidden-flag" // DisableDependentModules is the control that prevents the use of the deprecated `--disable-dependent-modules` flag. DisableDependentModules = "disable-dependent-modules" ) //nolint:lll func New() strict.Controls { lifecycleCategory := &strict.Category{ Name: "Lifecycle controls", } stageCategory := &strict.Category{ Name: "Stage controls", } skipDependenciesInputsControl := &Control{ Name: SkipDependenciesInputs, Description: "Controls whether to allow the deprecated dependency inputs feature. Dependency inputs are now disabled by default for performance. Use dependency outputs instead.", Error: errors.Errorf("Reading inputs from dependencies is no longer supported. To acquire values from dependencies, use outputs."), Warning: "Reading inputs from dependencies has been deprecated and is now disabled by default for performance. Use dependency outputs instead.", Category: stageCategory, Status: strict.CompletedStatus, } requireExplicitBootstrapControl := &Control{ Name: RequireExplicitBootstrap, Description: "Don't bootstrap backends by default. When enabled, users must supply `--backend-bootstrap` explicitly to automatically bootstrap backend resources.", Error: errors.Errorf("Bootstrap backend for remote state by default is no longer supported. Use `--backend-bootstrap` flag instead."), Warning: "Bootstrapping backend resources by default is deprecated functionality, and will not be the default behavior in a future version of Terragrunt. Use the explicit `--backend-bootstrap` flag to automatically provision backend resources before they're needed.", Category: stageCategory, Status: strict.CompletedStatus, } controls := strict.Controls{ &Control{ Name: DeprecatedCommands, Description: "Prevents deprecated commands from being used.", Category: lifecycleCategory, }, &Control{ Name: DeprecatedFlags, Description: "Prevents deprecated flags from being used.", Category: lifecycleCategory, }, &Control{ Name: DeprecatedEnvVars, Description: "Prevents deprecated env vars from being used.", Category: lifecycleCategory, }, &Control{ Name: DeprecatedConfigs, Description: "Prevents deprecated config syntax from being used.", Category: lifecycleCategory, Subcontrols: strict.Controls{ skipDependenciesInputsControl, requireExplicitBootstrapControl, }, }, skipDependenciesInputsControl, requireExplicitBootstrapControl, &Control{ Name: CLIRedesign, Description: "Prevents the use of commands deprecated as part of the CLI Redesign.", Category: stageCategory, }, &Control{ Name: LegacyAll, Description: "Prevents old *-all commands such as plan-all from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "spin-up", Description: "Prevents the deprecated spin-up command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "tear-down", Description: "Prevents the deprecated tear-down command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "plan-all", Description: "Prevents the deprecated plan-all command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "apply-all", Description: "Prevents the deprecated apply-all command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "destroy-all", Description: "Prevents the deprecated destroy-all command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "output-all", Description: "Prevents the deprecated output-all command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: "validate-all", Description: "Prevents the deprecated validate-all command from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: TerragruntPrefixFlags, Description: "Prevents deprecated flags with `terragrunt-` prefixes from being used.", Category: stageCategory, Status: strict.CompletedStatus, }, &Control{ Name: TerragruntPrefixEnvVars, Description: "Prevents deprecated env vars with `TERRAGRUNT_` prefixes from being used.", Category: stageCategory, }, &Control{ Name: DefaultCommands, Description: "Prevents default commands from being used.", Category: stageCategory, }, &Control{ Name: LegacyLogs, Description: "Prevents old log flags from being used.", Category: stageCategory, }, &Control{ Name: RootTerragruntHCL, Description: "Throw an error when users try to reference a root terragrunt.hcl file using find_in_parent_folders.", Error: errors.New("Using `terragrunt.hcl` as the root of Terragrunt configurations is an anti-pattern, and no longer supported. Use a differently named file like `root.hcl` instead. For more information, see https://docs.terragrunt.com/migrate/migrating-from-root-terragrunt-hcl"), Warning: "Using `terragrunt.hcl` as the root of Terragrunt configurations is an anti-pattern, and no longer recommended. In a future version of Terragrunt, this will result in an error. You are advised to use a differently named file like `root.hcl` instead. For more information, see https://docs.terragrunt.com/migrate/migrating-from-root-terragrunt-hcl", Category: stageCategory, }, &Control{ Name: BareInclude, Description: "Prevents the use of the `include` block without a label.", Category: stageCategory, Error: errors.New("Using an `include` block without a label is deprecated. Please use the `include` block with a label instead."), Warning: "Using an `include` block without a label is deprecated. Please use the `include` block with a label instead. For more information, see https://docs.terragrunt.com/migrate/bare-include/", }, &Control{ Name: DoubleStar, Description: "Use the `**` glob pattern to select all files in a directory and its subdirectories.", Category: stageCategory, Error: errors.New("Using `**` to select all files in a directory and its subdirectories is enabled. **/* now matches subdirectories with at least a depth of one."), Warning: "Using `**` to select all files in a directory and its subdirectories is enabled. **/* now matches subdirectories with at least a depth of one.", Status: strict.CompletedStatus, }, &Control{ Name: QueueExcludeExternal, Description: "Prevents the use of the deprecated `--queue-exclude-external` flag.", Category: stageCategory, Error: errors.New("The `--queue-exclude-external` flag is no longer supported. External dependencies are now excluded by default. Use --queue-include-external to include them."), Warning: "The `--queue-exclude-external` flag is deprecated and will be removed in a future version of Terragrunt. External dependencies are now excluded by default.", }, &Control{ Name: QueueStrictInclude, Description: "Prevents the use of the deprecated `--queue-strict-include` flag.", Category: stageCategory, Error: errors.New("The `--queue-strict-include` flag is no longer supported. The behavior of Terragrunt when using `--queue-strict-include` is now the default behavior."), Warning: "The `--queue-strict-include` flag is deprecated and will be removed in a future version of Terragrunt. The behavior of Terragrunt when using `--queue-strict-include` is now the default behavior.", }, &Control{ Name: UnitsThatInclude, Description: "Prevents the use of the deprecated `--units-that-include` flag.", Category: stageCategory, Error: errors.New("The `--units-that-include` flag is no longer supported. Use `--filter='reading='` to include units that include or read the specified configuration."), Warning: "The `--units-that-include` flag is deprecated and will be removed in a future version of Terragrunt. Use `--filter='reading='` to include units that include or read the specified configuration.", }, &Control{ Name: DisableCommandValidation, Description: "Prevents the use of the deprecated `--disable-command-validation` flag. Command validation has been removed entirely.", Category: stageCategory, Error: errors.New("The `--disable-command-validation` flag is no longer supported. Command validation has been removed entirely, and you can pass any command to `terragrunt run`."), Warning: "The `--disable-command-validation` flag is deprecated and will be removed in a future version of Terragrunt. Command validation has been removed entirely, and you can pass any command to `terragrunt run`.", }, &Control{ Name: NoDestroyDependenciesCheck, Description: "Prevents the use of the deprecated `--no-destroy-dependencies-check` flag. This flag is now ignored. Use `--destroy-dependencies-check` to enable dependency checks during destroy operations.", Category: stageCategory, Error: errors.New("The `--no-destroy-dependencies-check` flag is no longer supported. Use `--destroy-dependencies-check` to enable dependency checks during destroy operations."), Warning: "The `--no-destroy-dependencies-check` flag is deprecated and will be removed in a future version of Terragrunt. This flag is now ignored. Use `--destroy-dependencies-check` to enable dependency checks during destroy operations.", }, &Control{ Name: InternalTFLint, Description: "Prevents the use of the deprecated embedded version of tflint, instead treating `tflint` as a normal hook.", Category: stageCategory, Error: errors.New("The embedded version of tflint is no longer supported. Use the `--terragrunt-external-tflint` flag in your hook to opt in to running tflint externally."), Warning: "The embedded version of tflint is deprecated and will be removed in a future version of Terragrunt. Use the `--terragrunt-external-tflint` flag in your hook to opt in to running tflint externally and avoid this warning.", }, &Control{ Name: DeprecatedHiddenFlag, Description: "Prevents the use of the deprecated `--hidden` flag.", Category: stageCategory, Error: errors.New("The `--hidden` flag is no longer supported. Hidden directories are now included by default. Use `--no-hidden` to exclude them."), Warning: "The `--hidden` flag is deprecated and will be removed in a future version of Terragrunt. Hidden directories are now included by default. Use `--no-hidden` to exclude them.", }, &Control{ Name: DisableDependentModules, Description: "Prevents the use of the deprecated `--disable-dependent-modules` flag.", Category: stageCategory, Error: errors.New("The `--disable-dependent-modules` flag is no longer supported. Dependent modules discovery has been removed from `terragrunt render`."), Warning: "The `--disable-dependent-modules` flag is deprecated and will be removed in a future version of Terragrunt. Dependent modules discovery has been removed from `terragrunt render`, so this flag has no effect.", }, } return controls.Sort() } ================================================ FILE: internal/strict/controls/deprecated_command.go ================================================ package controls import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" ) const ( CLIRedesignCommandsCategoryName = "CLI redesign commands" ) // NewDeprecatedReplacedCommand declares the deprecated command that has an alternative command. func NewDeprecatedReplacedCommand(command, newCommand string) *Control { return &Control{ Name: command, Description: "replaced with: " + newCommand, Error: errors.Errorf("The `%s` command is no longer supported. Use `%s` instead.", command, newCommand), Warning: fmt.Sprintf("The `%s` command is deprecated and will be removed in a future version of Terragrunt. Use `%s` instead.", command, newCommand), } } // NewDeprecatedCommand declares the deprecated command. func NewDeprecatedCommand(command string) *Control { return &Control{ Name: command, Description: "no replaced command", Error: errors.Errorf("The `%s` command is no longer supported.", command), Warning: fmt.Sprintf("The `%s` command is deprecated and will be removed in a future version of Terragrunt.", command), } } ================================================ FILE: internal/strict/controls/deprecated_env_var.go ================================================ package controls import ( "context" "slices" "strconv" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( GlobalEnvVarsCategoryName = "Global env vars" CommandEnvVarsCategoryNameFmt = "`%s` command env vars" ) var _ = strict.Control(new(DeprecatedEnvVar)) // DeprecatedEnvVar is strict control for deprecated environment variables. type DeprecatedEnvVar struct { deprecatedFlag clihelper.Flag newFlag clihelper.Flag *Control ErrorFmt string WarningFmt string } // NewDeprecatedEnvVar returns a new `DeprecatedEnvVar` instance. // Since we don't know which env vars can be used at the time of definition, // we take the first env var from the list `GetEnvVars()` for the name and description to display it in `info strict`. func NewDeprecatedEnvVar(deprecatedFlag, newFlag clihelper.Flag, newValue string) *DeprecatedEnvVar { var ( deprecatedName = util.FirstNonEmpty(deprecatedFlag.GetEnvVars()) newName = util.FirstNonEmpty(newFlag.GetEnvVars()) ) if newValue != "" { newName += "=" + newValue } return &DeprecatedEnvVar{ Control: &Control{ Name: deprecatedName, Description: "replaced with: " + newName, }, ErrorFmt: "The `%s` environment variable is no longer supported. Use `%s` instead.", // The `TERRAGRUNT_LOG_LEVEL` environment variable is deprecated and will be removed in a future version of Terragrunt. Use `TG_LOG_LEVEL=trace` instead. WarningFmt: "The `%s` environment variable is deprecated and will be removed in a future version of Terragrunt. Use `%s` instead.", deprecatedFlag: deprecatedFlag, newFlag: newFlag, } } // Evaluate implements `strict.Control` interface. func (ctrl *DeprecatedEnvVar) Evaluate(ctx context.Context) error { var ( valueName = ctrl.deprecatedFlag.Value().GetName() envName string ) if valueName == "" || !ctrl.deprecatedFlag.Value().IsEnvSet() || !slices.Contains(ctrl.deprecatedFlag.GetEnvVars(), valueName) { return nil } if names := ctrl.newFlag.GetEnvVars(); len(names) > 0 { envName = names[0] value := ctrl.newFlag.Value().String() if v, ok := ctrl.newFlag.Value().Get().(bool); ok && ctrl.newFlag.Value().IsNegativeBoolFlag() { value = strconv.FormatBool(!v) } if value == "" { value = ctrl.deprecatedFlag.Value().String() } envName += "=" + value } if ctrl.Enabled { if ctrl.Status != strict.ActiveStatus || ctrl.ErrorFmt == "" { return nil } return errors.Errorf(ctrl.ErrorFmt, valueName, envName) } if logger := log.LoggerFromContext(ctx); logger != nil && ctrl.WarningFmt != "" && !ctrl.isSuppressed() { ctrl.OnceWarn.Do(func() { logger.Warnf(ctrl.WarningFmt, valueName, envName) }) } return nil } ================================================ FILE: internal/strict/controls/deprecated_flag_name.go ================================================ package controls import ( "context" "slices" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( GlobalFlagsCategoryName = "Global flags" CommandFlagsCategoryNameFmt = "`%s` command flags" ) var _ = strict.Control(new(DeprecatedFlagName)) // DeprecatedFlagName is strict control for deprecated flag names. type DeprecatedFlagName struct { deprecatedFlag clihelper.Flag newFlag clihelper.Flag *Control ErrorFmt string WarningFmt string } // NewDeprecatedFlagName returns a new `DeprecatedFlagName` instance. // Since we don't know which names can be used at the time of definition, // we take the first name from the list `Names()` for the name and description to display it in `info strict`. func NewDeprecatedFlagName(deprecatedFlag, newFlag clihelper.Flag, newValue string) *DeprecatedFlagName { var ( deprecatedName = util.FirstNonEmpty(deprecatedFlag.Names()) newName = util.FirstNonEmpty(newFlag.Names()) ) if newValue != "" { newName += "=" + newValue } return &DeprecatedFlagName{ Control: &Control{ Name: deprecatedName, Description: "replaced with: " + newName, }, ErrorFmt: "The `--%s` flag is no longer supported. Use `--%s` instead.", // Output example: // The `--terragrunt-working-dir` flag is deprecated and will be removed in a future version of Terragrunt. Use `--working-dir=./test/fixtures/extra-args/` instead. WarningFmt: "The `--%s` flag is deprecated and will be removed in a future version of Terragrunt. Use `--%s` instead.", deprecatedFlag: deprecatedFlag, newFlag: newFlag, } } // Evaluate implements `strict.Control` interface. func (ctrl *DeprecatedFlagName) Evaluate(ctx context.Context) error { var ( valueName = ctrl.deprecatedFlag.Value().GetName() flagName string ) if valueName == "" || !ctrl.deprecatedFlag.Value().IsArgSet() || !slices.Contains(ctrl.deprecatedFlag.Names(), valueName) { return nil } if names := ctrl.newFlag.Names(); len(names) > 0 { flagName = names[0] if ctrl.newFlag.TakesValue() { value := ctrl.newFlag.Value().String() if value == "" { value = ctrl.deprecatedFlag.Value().String() } flagName += "=" + value } } if ctrl.Enabled { if ctrl.Status != strict.ActiveStatus || ctrl.ErrorFmt == "" { return nil } return errors.Errorf(ctrl.ErrorFmt, valueName, flagName) } if logger := log.LoggerFromContext(ctx); logger != nil && ctrl.WarningFmt != "" && !ctrl.isSuppressed() { ctrl.OnceWarn.Do(func() { logger.Warnf(ctrl.WarningFmt, valueName, flagName) }) } return nil } ================================================ FILE: internal/strict/errors.go ================================================ package strict // InvalidControlNameError is an error that is returned when an invalid control name is requested. type InvalidControlNameError struct { allowedNames ControlNames } func NewInvalidControlNameError(allowedNames ControlNames) *InvalidControlNameError { return &InvalidControlNameError{ allowedNames: allowedNames, } } func (err InvalidControlNameError) Error() string { return "allowed control(s): " + err.allowedNames.String() } ================================================ FILE: internal/strict/status.go ================================================ package strict import "slices" const ( // ActiveStatus is the Status of a control that is ongoing. ActiveStatus Status = iota // CompletedStatus is the Status of a Control that is completed. CompletedStatus // SuspendedStatus is the Status of a Control that is suspended. // It does nothing and is assigned to a control only to avoid returning the `InvalidControlNameError`. SuspendedStatus ) var statusNames = map[Status]string{ ActiveStatus: "Active", CompletedStatus: "Completed", SuspendedStatus: "Suspended", } // Statuses are a set of Statuses. type Statuses []Status // Contains returns true if the `statuses` slice contains the given `status`. func (statuses Statuses) Contains(status Status) bool { return slices.Contains(statuses, status) } // Status represents the status of the Control. type Status byte // String implements `fmt.Stringer` interface. func (status Status) String() string { if name, ok := statusNames[status]; ok { return name } return "unknown" } const ( greenColor = "\033[0;32m" yellowColor = "\033[0;33m" resetColor = "\033[0m" ) // StringWithANSIColor returns a colored text representation of the status. func (status Status) StringWithANSIColor() string { str := status.String() switch status { case ActiveStatus: return greenColor + str + resetColor case CompletedStatus, SuspendedStatus: return yellowColor + str + resetColor } return str } ================================================ FILE: internal/strict/strict.go ================================================ // Package strict provides utilities used by Terragrunt to support a "strict" mode. // By default strict mode is disabled, but when Enabled, any breaking changes // to Terragrunt behavior that is not backwards compatible will result in an error. // // Note that any behavior outlined here should be documented in docs/src/content/docs/04-reference/03-strict-controls.mdx // // That is how users will know what to expect when they enable strict mode, and how to customize it. package strict ================================================ FILE: internal/strict/view/plaintext/plaintext.go ================================================ // Package plaintext implements the view.Render interface for displaying strict controls in plaintext format. package plaintext ================================================ FILE: internal/strict/view/plaintext/render.go ================================================ package plaintext import ( "bytes" "text/tabwriter" "text/template" "maps" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/view" ) const ( tabMinWidth = 1 tabWidth = 8 tabPadding = 2 ) var _ = view.Render(new(Render)) type Render struct{} func NewRender() *Render { return &Render{} } // List implements view.Render interface. func (render *Render) List(controls strict.Controls) (string, error) { result, err := render.executeTemplate(listTemplate, map[string]any{ "controls": controls, }, nil) if err != nil { return "", errors.Errorf("failed to render controls list: %w", err) } return result, nil } // DetailControl implements view.Render interface. func (render *Render) DetailControl(control strict.Control) (string, error) { return render.executeTemplate(detailControlTemplate, map[string]any{"control": control}, nil) } func (render *Render) buildTemplate(templ string, customFuncs map[string]any) (*template.Template, error) { funcMap := template.FuncMap{} maps.Copy(funcMap, customFuncs) t := template.Must(template.New("template").Funcs(funcMap).Parse(templ)) templates := map[string]string{ "subcontrolTemplate": subcontrolTemplate, "controlTemplate": controlTemplate, "rangeSubcontrolsTemplate": rangeSubcontrolsTemplate, "rangeControlsTemplate": rangeControlsTemplate, } for name, value := range templates { if _, err := t.New(name).Parse(value); err != nil { return nil, errors.Errorf("failed to parse template %s: %w", name, err) } } return t, nil } func (render *Render) formatOutput(t *template.Template, data any) (string, error) { out := new(bytes.Buffer) tabOut := tabwriter.NewWriter(out, tabMinWidth, tabWidth, tabPadding, ' ', 0) if err := t.Execute(tabOut, data); err != nil { return "", errors.Errorf("failed to execute template: %w", err) } if err := tabOut.Flush(); err != nil { return "", errors.Errorf("failed to flush output: %w", err) } return out.String(), nil } func (render *Render) executeTemplate(templ string, data any, customFuncs map[string]any) (string, error) { t, err := render.buildTemplate(templ, customFuncs) if err != nil { return "", err } return render.formatOutput(t, data) } ================================================ FILE: internal/strict/view/plaintext/template.go ================================================ package plaintext const controlTemplate = `{{ .Name }}{{ "\t" }}{{ .Status.StringWithANSIColor }}{{ "\t" }}{{ if .Description }}{{ .Description }}{{ else }}{{ .Warning }}{{ end }}` const rangeControlsTemplate = `{{ range $index, $control := .Sort }}{{ if $index }} {{ end }}{{ template "controlTemplate" $control }}{{ end }}` const subcontrolTemplate = `{{ .Name }}{{ "\t" }}{{ if .Description }}{{ .Description }}{{ else }}{{ .Warning }}{{ end }}` const rangeSubcontrolsTemplate = `{{ range $index, $control := .Sort }}{{ if $index }} {{ end }}{{ template "subcontrolTemplate" $control }}{{ end }}` const listTemplate = ` {{ $controls := .controls }}{{ $categories := $controls.GetCategories.FilterNotHidden.Sort }}{{ range $index, $category := $categories }}{{ if $index }} {{ end }}{{ $category.Name }}: {{ $categoryControls := $controls.FilterByCategories $category }}{{ template "rangeControlsTemplate" $categoryControls }} {{ end }}{{ $noCategoryControls := $controls.FilterByCategories }}{{ if $noCategoryControls }} {{ template "rangeControlsTemplate" $noCategoryControls }} {{ end }} ` const detailControlTemplate = ` {{ $controls := .control.GetSubcontrols.RemoveDuplicates }}{{ $categories := $controls.GetCategories.FilterNotHidden.Sort }}{{ range $index, $category := $categories }}{{ if $index }} {{ end }}{{ $category.Name }}: {{ $categoryControls := $controls.FilterByCategories $category }}{{ template "rangeSubcontrolsTemplate" $categoryControls }} {{ end }}{{ $noCategoryControls := $controls.FilterByCategories }}{{ if and $categories $noCategoryControls }} {{ end }}{{ if $noCategoryControls }}{{ template "rangeSubcontrolsTemplate" $noCategoryControls }} {{ end }} ` ================================================ FILE: internal/strict/view/render.go ================================================ package view import "github.com/gruntwork-io/terragrunt/internal/strict" type Render interface { // List renders the list of controls. List(controls strict.Controls) (string, error) // DetailControl renders the detailed information about the control, including its subcontrols. DetailControl(control strict.Control) (string, error) } ================================================ FILE: internal/strict/view/view.go ================================================ // Package view contains the rendering logic for printing strict controls. package view ================================================ FILE: internal/strict/view/writer.go ================================================ package view import ( "fmt" "io" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/strict" ) // Writer is the base layer for command views, encapsulating a set of I/O streams and implementing a human friendly view for strict controls. type Writer struct { io.Writer render Render } // NewWriter returns a new Writer instance that uses the provided io.Writer for output // and the Render interface for formatting controls. func NewWriter(writer io.Writer, render Render) *Writer { return &Writer{ Writer: writer, render: render, } } // List renders the given list of controls. func (writer *Writer) List(controls strict.Controls) error { output, err := writer.render.List(controls) if err != nil { return err } return writer.output(output) } // DetailControl renders the detailed information about the control, including its subcontrols. func (writer *Writer) DetailControl(control strict.Control) error { output, err := writer.render.DetailControl(control) if err != nil { return err } return writer.output(output) } func (writer *Writer) output(output string) error { if _, err := fmt.Fprint(writer, output); err != nil { return errors.Errorf("failed to write output: %w", err) } return nil } ================================================ FILE: internal/telemetry/context.go ================================================ package telemetry import ( "context" "fmt" "go.opentelemetry.io/otel/trace" ) type contextKey byte const ( telemeterContextKey contextKey = iota TraceParentEnv = "TRACEPARENT" ) // ContextWithTelemeter returns a new context with the provided Telemeter attached. func ContextWithTelemeter(ctx context.Context, telemeter *Telemeter) context.Context { return context.WithValue(ctx, telemeterContextKey, telemeter) } // TelemeterFromContext retrieves the Telemeter from the context, or nil if not present. func TelemeterFromContext(ctx context.Context) *Telemeter { if val := ctx.Value(telemeterContextKey); val != nil { if telemeter, ok := val.(*Telemeter); ok { return telemeter } } return new(Telemeter) } // TraceParentFromContext returns the W3C traceparent header value from the context's span, or an error if not available. func TraceParentFromContext(ctx context.Context, telemetry *Options) string { span := trace.SpanFromContext(ctx) spanContext := span.SpanContext() if !spanContext.IsValid() { return "" } if len(telemetry.TraceParent) > 0 { return telemetry.TraceParent } traceID := spanContext.TraceID().String() spanID := spanContext.SpanID().String() flags := "00" if spanContext.TraceFlags().IsSampled() { flags = "01" } return fmt.Sprintf("00-%s-%s-%s", traceID, spanID, flags) } ================================================ FILE: internal/telemetry/errors.go ================================================ package telemetry import "fmt" // ErrorMissingEnvVariable error for missing environment variable. type ErrorMissingEnvVariable struct { Vars []string } func (e *ErrorMissingEnvVariable) Error() string { return fmt.Sprintf("missing environment variable: %v", e.Vars) } ================================================ FILE: internal/telemetry/meter.go ================================================ package telemetry import ( "context" "io" "regexp" "time" "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" "github.com/gruntwork-io/terragrunt/internal/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" ) const ( noneMetricsExporterType metricsExporterType = "none" consoleMetricsExporterType metricsExporterType = "console" oltpHTTPMetricsExporterType metricsExporterType = "otlpHttp" grpcHTTPMetricsExporterType metricsExporterType = "grpcHttp" ErrorsCounter = "errors" readerInterval = 1 * time.Second ) var ( metricNameCleanPattern = regexp.MustCompile(`[^A-Za-z0-9_.-/]`) multipleUnderscoresPattern = regexp.MustCompile(`_+`) ) type metricsExporterType string type Meter struct { otelmetric.Meter provider *metric.MeterProvider exporter metric.Exporter } // NewMeter creates and configures the metrics collection. func NewMeter(ctx context.Context, appName, appVersion string, writer io.Writer, opts *Options) (*Meter, error) { exporter, err := NewMetricsExporter(ctx, writer, opts) if err != nil { return nil, errors.New(err) } if exporter == nil { return nil, nil } provider, err := newMetricsProvider(exporter, appName, appVersion) if err != nil { return nil, errors.New(err) } otel.SetMeterProvider(provider) meter := &Meter{ Meter: otel.GetMeterProvider().Meter(appName), provider: provider, exporter: exporter, } return meter, nil } // Time collects time for function execution func (meter *Meter) Time(ctx context.Context, name string, attrs map[string]any, fn func(childCtx context.Context) error) error { if meter == nil || meter.exporter == nil { return fn(ctx) } metricAttrs := mapToAttributes(attrs) histogram, err := meter.Int64Histogram(CleanMetricName(name + "_duration")) if err != nil { return errors.New(err) } startTime := time.Now() err = fn(ctx) histogram.Record(ctx, time.Since(startTime).Milliseconds(), otelmetric.WithAttributes(metricAttrs...)) if err != nil { // count errors meter.Count(ctx, ErrorsCounter, 1) meter.Count(ctx, name+"_errors", 1) } else { meter.Count(ctx, name+"_success", 1) } return err } // Count adds to counter provided value. func (meter *Meter) Count(ctx context.Context, name string, value int64) { if ctx == nil || meter == nil || meter.exporter == nil { return } counter, err := meter.Int64Counter(CleanMetricName(name + "_count")) if err != nil { return } counter.Add(ctx, value) } // NewMetricsExporter - create a new exporter based on the telemetry options. func NewMetricsExporter(ctx context.Context, writer io.Writer, opts *Options) (metric.Exporter, error) { exporterType := metricsExporterType(opts.MetricExporter) if exporterType == "" { exporterType = noneMetricsExporterType } // TODO: Remove this lint suppression switch exporterType { //nolint:exhaustive case oltpHTTPMetricsExporterType: var config []otlpmetrichttp.Option if opts.MetricExporterInsecureEndpoint { config = append(config, otlpmetrichttp.WithInsecure()) } return otlpmetrichttp.New(ctx, config...) case grpcHTTPMetricsExporterType: var config []otlpmetricgrpc.Option if opts.MetricExporterInsecureEndpoint { config = append(config, otlpmetricgrpc.WithInsecure()) } return otlpmetricgrpc.New(ctx, config...) case consoleMetricsExporterType: return stdoutmetric.New(stdoutmetric.WithWriter(writer)) default: return nil, nil } } // newMetricsProvider creates a new metrics provider. func newMetricsProvider(exp metric.Exporter, appName, appVersion string) (*metric.MeterProvider, error) { r, err := resource.Merge( resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName(appName), semconv.ServiceVersion(appVersion), ), ) if err != nil { return nil, errors.New(err) } meterProvider := metric.NewMeterProvider( metric.WithResource(r), metric.WithReader(metric.NewPeriodicReader(exp, metric.WithInterval(readerInterval))), ) return meterProvider, nil } ================================================ FILE: internal/telemetry/meter_test.go ================================================ package telemetry_test import ( "io" "testing" "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" ) func TestNewMetricsExporter(t *testing.T) { t.Parallel() ctx := t.Context() stdout, err := stdoutmetric.New() require.NoError(t, err) tests := []struct { expectedType any name string exporterType string insecure bool expectNil bool }{ { name: "OTLP HTTP Exporter", exporterType: "otlpHttp", insecure: false, expectedType: (*otlpmetrichttp.Exporter)(nil), }, { name: "gRPC HTTP Exporter", exporterType: "grpcHttp", insecure: true, expectedType: (*otlpmetricgrpc.Exporter)(nil), }, { name: "Console Exporter", exporterType: "console", insecure: false, expectedType: stdout, }, { name: "None Exporter", exporterType: "none", insecure: false, expectNil: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptionsWithWriters(io.Discard, io.Discard) opts.Telemetry.MetricExporter = tt.exporterType opts.Telemetry.MetricExporterInsecureEndpoint = tt.insecure exporter, err := telemetry.NewMetricsExporter(ctx, io.Discard, opts.Telemetry) require.NoError(t, err) if tt.expectNil { assert.Nil(t, exporter) } else { assert.IsType(t, tt.expectedType, exporter) } }) } } func TestCleanMetricName(t *testing.T) { t.Parallel() testCases := []struct { name string input string expected string }{ { name: "Normal case", input: "metricName_1.2-34", expected: "metricName_1.2_34", }, { name: "Starts with invalid characters", input: "!@#metricName", expected: "metricName", }, { name: "Ends with invalid characters", input: "metricName!@#", expected: "metricName", }, { name: "Only invalid characters", input: "!@#$%^&*()", expected: "", }, { name: "Empty string", input: "", expected: "", }, { name: "Leading underscore from replacement", input: "!metricName", expected: "metricName", }, { name: "Multiple replacements", input: "metric!@#Name", expected: "metric_Name", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := telemetry.CleanMetricName(tc.input) assert.Equal(t, tc.expected, result) }) } } ================================================ FILE: internal/telemetry/opts.go ================================================ package telemetry // Options are Telemetry options. type Options struct { // TraceExporter is the type of trace exporter to be used. TraceExporter string // TraceExporterHTTPEndpoint is the endpoint to which traces will be sent. TraceExporterHTTPEndpoint string // TraceParent is used as a parent trace context. TraceParent string // MetricExporter is the type of metrics exporter. MetricExporter string // TraceExporterInsecureEndpoint is useful for collecting traces locally. If set to true, the exporter will not validate the server certificate. TraceExporterInsecureEndpoint bool // MetricExporterInsecureEndpoint is useful for local metrics collection. if set to true, the exporter will not validate the server's certificate. MetricExporterInsecureEndpoint bool } ================================================ FILE: internal/telemetry/telemeter.go ================================================ // Package telemetry provides a way to collect telemetry from function execution - metrics and traces. package telemetry import ( "context" "io" "github.com/gruntwork-io/terragrunt/internal/errors" ) type Telemeter struct { *Tracer *Meter } // NewTelemeter initializes the telemetry collector. func NewTelemeter(ctx context.Context, appName, appVersion string, writer io.Writer, opts *Options) (*Telemeter, error) { tracer, err := NewTracer(ctx, appName, appVersion, writer, opts) if err != nil { return nil, errors.New(err) } meter, err := NewMeter(ctx, appName, appVersion, writer, opts) if err != nil { return nil, errors.New(err) } return &Telemeter{ Tracer: tracer, Meter: meter, }, nil } // Shutdown shutdowns the telemetry provider. func (tlm *Telemeter) Shutdown(ctx context.Context) error { if tlm.Tracer != nil && tlm.Tracer.provider != nil { if err := tlm.Tracer.provider.Shutdown(ctx); err != nil { return errors.New(err) } tlm.Tracer.provider = nil } if tlm.Meter != nil && tlm.Meter.provider != nil { if err := tlm.Meter.provider.Shutdown(ctx); err != nil { return errors.New(err) } tlm.Meter.provider = nil } return nil } // Collect collects telemetry from function execution metrics and traces. func (tlm *Telemeter) Collect(ctx context.Context, name string, attrs map[string]any, fn func(childCtx context.Context) error) error { // wrap telemetry collection with trace and time metric return tlm.Trace(ctx, name, attrs, func(ctx context.Context) error { return tlm.Time(ctx, name, attrs, fn) }) } ================================================ FILE: internal/telemetry/tracer.go ================================================ package telemetry import ( "context" "fmt" "io" "strconv" "strings" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "github.com/gruntwork-io/terragrunt/internal/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "go.opentelemetry.io/otel/trace" ) const ( noneTraceExporterType traceExporterType = "none" consoleTraceExporterType traceExporterType = "console" otlpHTTPTraceExporterType traceExporterType = "otlpHttp" otlpGrpcTraceExporterType traceExporterType = "otlpGrpc" httpTraceExporterType traceExporterType = "http" traceParentParts = 4 ) type traceExporterType string type Tracer struct { trace.Tracer provider *sdktrace.TracerProvider spanExporter sdktrace.SpanExporter parentTraceID *trace.TraceID parentSpanID *trace.SpanID parentTraceFlags *trace.TraceFlags } // NewTracer creates and configures the traces collection. func NewTracer(ctx context.Context, appName, appVersion string, writer io.Writer, opts *Options) (*Tracer, error) { spanExporter, err := NewTraceExporter(ctx, writer, opts) if err != nil { return nil, errors.New(err) } if spanExporter == nil { // no exporter return nil, nil } provider, err := newTraceProvider(spanExporter, appName, appVersion, opts) if err != nil { return nil, errors.New(err) } otel.SetTracerProvider(provider) var ( parentTraceID *trace.TraceID parentSpanID *trace.SpanID parentTraceFlags *trace.TraceFlags ) if opts.TraceParent != "" { // parse trace parent values parts := strings.Split(opts.TraceParent, "-") if len(parts) != traceParentParts { return nil, fmt.Errorf("invalid TRACEPARENT value %s", opts.TraceParent) } _, traceIDHex, spanIDHex, traceFlagsStr := parts[0], parts[1], parts[2], parts[3] parsedFlag, err := strconv.Atoi(traceFlagsStr) if err != nil { return nil, errors.Errorf("invalid trace flags: %w", err) } traceFlags := trace.FlagsSampled if parsedFlag == 0 { traceFlags = 0 } traceID, err := trace.TraceIDFromHex(traceIDHex) if err != nil { return nil, errors.New(err) } spanID, err := trace.SpanIDFromHex(spanIDHex) if err != nil { return nil, errors.New(err) } parentTraceID = &traceID parentSpanID = &spanID parentTraceFlags = &traceFlags } tracer := &Tracer{ Tracer: provider.Tracer(appName), provider: provider, spanExporter: spanExporter, parentTraceID: parentTraceID, parentSpanID: parentSpanID, parentTraceFlags: parentTraceFlags, } return tracer, nil } // newTraceProvider creates a new trace tracer with terragrunt version. func newTraceProvider(exp sdktrace.SpanExporter, appName, appVersion string, opts *Options) (*sdktrace.TracerProvider, error) { r, err := resource.Merge( resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName(appName), semconv.ServiceVersion(appVersion), ), ) if err != nil { return nil, errors.New(err) } exporterType := traceExporterType(opts.TraceExporter) var processor sdktrace.SpanProcessor if exporterType == consoleTraceExporterType { processor = sdktrace.NewSimpleSpanProcessor(exp) } else { processor = sdktrace.NewBatchSpanProcessor(exp) } return sdktrace.NewTracerProvider( sdktrace.WithSpanProcessor(processor), sdktrace.WithResource(r), ), nil } // NewTraceExporter creates a new exporter based on the telemetry options. func NewTraceExporter(ctx context.Context, writer io.Writer, opts *Options) (sdktrace.SpanExporter, error) { exporterType := traceExporterType(opts.TraceExporter) if exporterType == "" { exporterType = noneTraceExporterType } // TODO: Remove lint suppression switch exporterType { //nolint:exhaustive case httpTraceExporterType: if opts.TraceExporterHTTPEndpoint == "" { return nil, &ErrorMissingEnvVariable{ Vars: []string{"TG_TELEMETRY_TRACE_EXPORTER_HTTP_ENDPOINT"}, } } endpointOpt := otlptracehttp.WithEndpoint(opts.TraceExporterHTTPEndpoint) config := []otlptracehttp.Option{endpointOpt} if opts.TraceExporterInsecureEndpoint { config = append(config, otlptracehttp.WithInsecure()) } return otlptracehttp.New(ctx, config...) case otlpHTTPTraceExporterType: var config []otlptracehttp.Option if opts.TraceExporterInsecureEndpoint { config = append(config, otlptracehttp.WithInsecure()) } return otlptracehttp.New(ctx, config...) case otlpGrpcTraceExporterType: var config []otlptracegrpc.Option if opts.TraceExporterInsecureEndpoint { config = append(config, otlptracegrpc.WithInsecure()) } return otlptracegrpc.New(ctx, config...) case consoleTraceExporterType: return stdouttrace.New(stdouttrace.WithWriter(writer)) default: return nil, nil } } // Trace collects traces for method execution. func (tracer *Tracer) Trace(ctx context.Context, name string, attrs map[string]any, fn func(childCtx context.Context) error) error { if tracer == nil || tracer.spanExporter == nil || tracer.provider == nil { // invoke function without tracing return fn(ctx) } ctx, span := tracer.openSpan(ctx, name, attrs) defer span.End() if err := fn(ctx); err != nil { // record error in span span.RecordError(err) return err } return nil } // openSpan creates a new span with attributes. func (tracer *Tracer) openSpan(ctx context.Context, name string, attrs map[string]any) (context.Context, trace.Span) { if tracer.provider == nil { return ctx, nil } if tracer.parentTraceID != nil && tracer.parentSpanID != nil { existingSpan := trace.SpanFromContext(ctx) if !existingSpan.SpanContext().IsValid() { spanContext := trace.NewSpanContext(trace.SpanContextConfig{ TraceID: *tracer.parentTraceID, SpanID: *tracer.parentSpanID, Remote: true, TraceFlags: *tracer.parentTraceFlags, }) // create a new context with the parent span context ctx = trace.ContextWithSpanContext(ctx, spanContext) } } // This lint is suppressed because we definitely do close the span // in a defer statement everywhere openSpan is called. It seems like // a useful lint, though. We should consider removing the suppression // and fixing the lint. ctx, span := tracer.Start(ctx, name) // nolint:spancheck // convert attrs map to span.SetAttributes span.SetAttributes(mapToAttributes(attrs)...) return ctx, span //nolint:spancheck } ================================================ FILE: internal/telemetry/tracer_test.go ================================================ package telemetry_test import ( "io" "testing" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" ) func TestNewTraceExporter(t *testing.T) { t.Parallel() ctx := t.Context() http, err := otlptracehttp.New(ctx) require.NoError(t, err) grpc, err := otlptracegrpc.New(ctx) require.NoError(t, err) stdoutrace, err := stdouttrace.New() require.NoError(t, err) tests := []struct { expectedType any traceExporter string traceExporterHTTPEndpoint string name string expectError bool }{ { name: "HTTP Trace Exporter", traceExporter: "otlpHttp", expectedType: http, expectError: false, }, { name: "Custom HTTP endpoint", traceExporter: "http", traceExporterHTTPEndpoint: "http://localhost:4317", expectedType: http, expectError: false, }, { name: "Custom HTTP endpoint without endpoint", traceExporter: "http", expectedType: http, expectError: true, }, { name: "Grpc Trace Exporter", traceExporter: "otlpGrpc", expectedType: grpc, expectError: false, }, { name: "Console Trace Exporter", traceExporter: "console", expectedType: stdoutrace, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() opts := options.NewTerragruntOptionsWithWriters(io.Discard, io.Discard) opts.Telemetry.TraceExporter = tt.traceExporter opts.Telemetry.TraceExporterHTTPEndpoint = tt.traceExporterHTTPEndpoint exporter, err := telemetry.NewTraceExporter(ctx, io.Discard, opts.Telemetry) if tt.expectError { require.Error(t, err) } else { require.NoError(t, err) assert.IsType(t, tt.expectedType, exporter) } }) } } ================================================ FILE: internal/telemetry/util.go ================================================ package telemetry import ( "fmt" "strings" "go.opentelemetry.io/otel/attribute" ) // mapToAttributes converts map to attributes to pass to span.SetAttributes. func mapToAttributes(data map[string]any) []attribute.KeyValue { var attrs []attribute.KeyValue for k, v := range data { switch val := v.(type) { case string: attrs = append(attrs, attribute.String(k, val)) case int: attrs = append(attrs, attribute.Int64(k, int64(val))) case int64: attrs = append(attrs, attribute.Int64(k, val)) case float64: attrs = append(attrs, attribute.Float64(k, val)) case bool: attrs = append(attrs, attribute.Bool(k, val)) default: attrs = append(attrs, attribute.String(k, fmt.Sprintf("%v", val))) } } return attrs } // CleanMetricName cleans metric name from invalid characters. func CleanMetricName(metricName string) string { cleanedName := metricNameCleanPattern.ReplaceAllString(metricName, "_") cleanedName = multipleUnderscoresPattern.ReplaceAllString(cleanedName, "_") return strings.Trim(cleanedName, "_") } ================================================ FILE: internal/tf/cache/config.go ================================================ package cache import ( "net" "strconv" "time" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( defaultHostname = "localhost" defaultShutdownTimeout = time.Second * 30 ) type Option func(Config) Config func WithHostname(hostname string) Option { return func(cfg Config) Config { if hostname != "" { cfg.hostname = hostname } return cfg } } func WithPort(port int) Option { return func(cfg Config) Config { if port != 0 { cfg.port = port } return cfg } } func WithToken(token string) Option { return func(cfg Config) Config { cfg.token = token return cfg } } func WithProviderService(service *services.ProviderService) Option { return func(cfg Config) Config { cfg.providerService = service return cfg } } func WithProviderHandlers(handlers ...handlers.ProviderHandler) Option { return func(cfg Config) Config { cfg.providerHandlers = handlers return cfg } } func WithProxyProviderHandler(handler *handlers.ProxyProviderHandler) Option { return func(cfg Config) Config { cfg.proxyProviderHandler = handler return cfg } } func WithCacheProviderHTTPStatusCode(statusCode int) Option { return func(cfg Config) Config { cfg.cacheProviderHTTPStatusCode = statusCode return cfg } } func WithLogger(logger log.Logger) Option { return func(cfg Config) Config { cfg.logger = logger return cfg } } type Config struct { logger log.Logger providerService *services.ProviderService proxyProviderHandler *handlers.ProxyProviderHandler hostname string token string providerHandlers handlers.ProviderHandlers port int shutdownTimeout time.Duration cacheProviderHTTPStatusCode int } func NewConfig(opts ...Option) *Config { cfg := &Config{ hostname: defaultHostname, shutdownTimeout: defaultShutdownTimeout, logger: log.Default(), } return cfg.WithOptions(opts...) } func (cfg *Config) WithOptions(opts ...Option) *Config { for _, opt := range opts { *cfg = opt(*cfg) } return cfg } func (cfg *Config) Addr() string { return net.JoinHostPort(cfg.hostname, strconv.Itoa(cfg.port)) } ================================================ FILE: internal/tf/cache/controllers/discovery.go ================================================ package controllers import ( "net/http" "maps" "github.com/gruntwork-io/terragrunt/internal/tf/cache/router" "github.com/labstack/echo/v4" ) const ( discoveryPath = "/.well-known" ) type Endpointer interface { // Endpoints returns controller endpoints. Endpoints() map[string]any } type DiscoveryController struct { *router.Router Endpointers []Endpointer } // Register implements router.Controller.Register func (controller *DiscoveryController) Register(router *router.Router) { controller.Router = router.Group(discoveryPath) // Discovery Process // https://developer.hashicorp.com/terraform/internals/remote-service-discovery#discovery-process controller.GET("/terraform.json", controller.terraformAction) } func (controller *DiscoveryController) terraformAction(ctx echo.Context) error { endpoints := make(map[string]any) for _, endpointer := range controller.Endpointers { maps.Copy(endpoints, endpointer.Endpoints()) } return ctx.JSON(http.StatusOK, endpoints) } ================================================ FILE: internal/tf/cache/controllers/downloader.go ================================================ package controllers import ( "net/http" "net/url" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cache/router" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "github.com/labstack/echo/v4" ) const ( downloadPath = "/downloads" ) type DownloaderController struct { *router.Router ProviderService *services.ProviderService ProxyProviderHandler *handlers.ProxyProviderHandler } // Register implements router.Controller.Register func (controller *DownloaderController) Register(router *router.Router) { controller.Router = router.Group(downloadPath) // Download provider controller.GET("/:remote_host/:remote_path", controller.downloadProviderAction) } func (controller *DownloaderController) downloadProviderAction(ctx echo.Context) error { var ( remoteHost = ctx.Param("remote_host") remotePath = ctx.Param("remote_path") ) downloadURL := url.URL{ Scheme: "https", Host: remoteHost, Path: "/" + remotePath, } provider := &models.Provider{ ResponseBody: &models.ResponseBody{ DownloadURL: downloadURL.String(), }, } if cache := controller.ProviderService.GetProviderCache(provider); cache != nil { if path := cache.ArchivePath(); path != "" { controller.ProviderService.Logger().Debugf("Download cached provider %s", cache.Provider) return ctx.File(path) } } if err := controller.ProxyProviderHandler.Download(ctx, provider); err != nil { return err } return ctx.NoContent(http.StatusNotFound) } ================================================ FILE: internal/tf/cache/controllers/provider.go ================================================ // Package controllers provides the implementation of the controller for the provider endpoints. package controllers import ( "net/http" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cache/router" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/labstack/echo/v4" ) const ( // name using for the discovery providerName = "providers.v1" // URL path to this controller providerPath = "/providers" ) type ProviderController struct { Logger log.Logger DownloaderController router.Controller *router.Router AuthMiddleware echo.MiddlewareFunc ProxyProviderHandler *handlers.ProxyProviderHandler ProviderService *services.ProviderService ProviderHandlers []handlers.ProviderHandler Server http.Server CacheProviderHTTPStatusCode int } // Endpoints implements controllers.Endpointer.Endpoints func (controller *ProviderController) Endpoints() map[string]any { return map[string]any{providerName: controller.URL().Path} } // Register implements router.Controller.Register func (controller *ProviderController) Register(router *router.Router) { controller.Router = router.Group(providerPath) if controller.AuthMiddleware != nil { controller.Use(controller.AuthMiddleware) } // Api should be compliant with the Terraform Registry Protocol for providers. // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms // Get All Versions for a Single Provider // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider controller.GET("/:cache_request_id/:registry_name/:namespace/:name/versions", controller.getVersionsAction) // Get a Platform // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-a-platform controller.GET("/:cache_request_id/:registry_name/:namespace/:name/:version/download/:os/:arch", controller.getPlatformsAction) } func (controller *ProviderController) getVersionsAction(ctx echo.Context) error { var ( registryName = ctx.Param("registry_name") namespace = ctx.Param("namespace") name = ctx.Param("name") ) provider := &models.Provider{ RegistryName: registryName, Namespace: namespace, Name: name, } var allVersions models.Versions for _, handler := range controller.ProviderHandlers { if handler.CanHandleProvider(provider) { versions, err := handler.GetVersions(ctx.Request().Context(), provider) if err != nil { controller.Logger.Errorf("Failed to get provider versions from %q: %s", handler, err.Error()) } if versions != nil { allVersions = append(allVersions, versions...) } } } validVersions, invalidVersions := allVersions.FilterValid() for _, v := range invalidVersions { controller.Logger.Warnf("Skipping invalid version %q for provider %s", v, provider.Address()) } versions := struct { ID string `json:"id"` Versions models.Versions `json:"versions"` }{ ID: provider.Address(), Versions: validVersions, } return ctx.JSON(http.StatusOK, versions) } func (controller *ProviderController) getPlatformsAction(ctx echo.Context) (er error) { var ( registryName = ctx.Param("registry_name") namespace = ctx.Param("namespace") name = ctx.Param("name") version = ctx.Param("version") os = ctx.Param("os") arch = ctx.Param("arch") cacheRequestID = ctx.Param("cache_request_id") ) provider := &models.Provider{ RegistryName: registryName, Namespace: namespace, Name: name, Version: version, OS: os, Arch: arch, } if cacheRequestID == "" { return controller.ProxyProviderHandler.GetPlatform(ctx, provider, controller.DownloaderController) } var ( resp *models.ResponseBody err error ) for _, handler := range controller.ProviderHandlers { if handler.CanHandleProvider(provider) { resp, err = handler.GetPlatform(ctx.Request().Context(), provider) if err != nil { controller.Logger.Errorf("Failed to get provider platform from %q: %s", handler, err.Error()) } if resp != nil { break } } } provider.ResponseBody = resp // start caching and return 423 status controller.ProviderService.CacheProvider(ctx.Request().Context(), cacheRequestID, provider) return ctx.NoContent(controller.CacheProviderHTTPStatusCode) } ================================================ FILE: internal/tf/cache/handlers/common_provider.go ================================================ // Package handlers provides the interfaces and common implementations for handling provider requests. package handlers import ( "context" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/puzpuzpuz/xsync/v3" ) type CommonProviderHandler struct { logger log.Logger // registryURLCache stores discovered registry URLs // We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map) // instead of standard `sync.Map` since it's faster and has generic types. registryURLCache *xsync.MapOf[string, *RegistryURLs] // includeProviders and excludeProviders are sets of provider matching patterns that together define which providers are eligible to be potentially installed from the corresponding Source. includeProviders models.Providers excludeProviders models.Providers } // NewCommonProviderHandler returns a new `CommonProviderHandler` instance with the defined values. func NewCommonProviderHandler(logger log.Logger, includes, excludes *[]string) *CommonProviderHandler { var includeProviders, excludeProviders models.Providers if includes != nil { includeProviders = models.ParseProviders(*includes...) } if excludes != nil { excludeProviders = models.ParseProviders(*excludes...) } return &CommonProviderHandler{ logger: logger, includeProviders: includeProviders, excludeProviders: excludeProviders, registryURLCache: xsync.NewMapOf[string, *RegistryURLs](), } } // CanHandleProvider implements ProviderHandler.CanHandleProvider func (handler *CommonProviderHandler) CanHandleProvider(provider *models.Provider) bool { switch { case handler.excludeProviders.Find(provider) != nil: return false case len(handler.includeProviders) > 0: return handler.includeProviders.Find(provider) != nil default: return true } } // DiscoveryURL implements ProviderHandler.DiscoveryURL. func (handler *CommonProviderHandler) DiscoveryURL(ctx context.Context, registryName string) (*RegistryURLs, error) { if urls, ok := handler.registryURLCache.Load(registryName); ok { return urls, nil } urls, err := DiscoveryURL(ctx, registryName) if err != nil { if !IsOfflineError(err) { return nil, err } urls = DefaultRegistryURLs handler.logger.Debugf("Unable to discover %q registry URLs, reason: %q, use default URLs: %s", registryName, err, urls) } else { handler.logger.Debugf("Discovered %q registry URLs: %s", registryName, urls) } handler.registryURLCache.Store(registryName, urls) return urls, nil } ================================================ FILE: internal/tf/cache/handlers/direct_provider.go ================================================ package handlers import ( "context" "net/http" "net/url" "path" "github.com/gruntwork-io/terragrunt/internal/tf/cache/helpers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" ) var _ ProviderHandler = new(DirectProviderHandler) type DirectProviderHandler struct { *CommonProviderHandler client *helpers.Client } func NewDirectProviderHandler(logger log.Logger, method *cliconfig.ProviderInstallationDirect, credsSource *cliconfig.CredentialsSource) *DirectProviderHandler { return &DirectProviderHandler{ CommonProviderHandler: NewCommonProviderHandler(logger, method.Include, method.Exclude), client: helpers.NewClient(credsSource), } } func (handler *DirectProviderHandler) String() string { return "direct" } // GetVersions implements ProviderHandler.GetVersions // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider // //nolint:lll func (handler *DirectProviderHandler) GetVersions(ctx context.Context, provider *models.Provider) (models.Versions, error) { apiURLs, err := handler.DiscoveryURL(ctx, provider.RegistryName) if err != nil { return nil, err } reqURL := &url.URL{ Scheme: "https", Host: provider.RegistryName, Path: path.Join(apiURLs.ProvidersV1, provider.Namespace, provider.Name, "versions"), } versions := struct { Versions models.Versions `json:"versions"` }{} if err := handler.client.Do(ctx, http.MethodGet, reqURL.String(), &versions); err != nil { return nil, err } return versions.Versions, nil } // GetPlatform implements ProviderHandler.GetPlatform func (handler *DirectProviderHandler) GetPlatform(ctx context.Context, provider *models.Provider) (*models.ResponseBody, error) { apiURLs, err := handler.DiscoveryURL(ctx, provider.RegistryName) if err != nil { return nil, err } platformURL := &url.URL{ Scheme: "https", Host: provider.RegistryName, Path: path.Join(apiURLs.ProvidersV1, provider.Namespace, provider.Name, provider.Version, "download", provider.OS, provider.Arch), } var resp = new(models.ResponseBody) if err := handler.client.Do(ctx, http.MethodGet, platformURL.String(), resp); err != nil { return nil, err } resp = resp.ResolveRelativeReferences(platformURL) return resp, nil } ================================================ FILE: internal/tf/cache/handlers/errors.go ================================================ package handlers type NotFoundWellKnownURLError struct { url string } func (err NotFoundWellKnownURLError) Error() string { return err.url + " not found" } ================================================ FILE: internal/tf/cache/handlers/filesystem_mirror_provider.go ================================================ package handlers import ( "context" "encoding/json" "os" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) var _ ProviderHandler = new(FilesystemMirrorProviderHandler) type FilesystemMirrorProviderHandler struct { *CommonProviderHandler filesystemMirrorPath string } func NewFilesystemMirrorProviderHandler(logger log.Logger, method *cliconfig.ProviderInstallationFilesystemMirror) *FilesystemMirrorProviderHandler { return &FilesystemMirrorProviderHandler{ CommonProviderHandler: NewCommonProviderHandler(logger, method.Include, method.Exclude), filesystemMirrorPath: method.Path, } } func (handler *FilesystemMirrorProviderHandler) String() string { return "filesystem_mirror '" + handler.filesystemMirrorPath + "'" } // GetVersions implements ProviderHandler.GetVersions func (handler *FilesystemMirrorProviderHandler) GetVersions(_ context.Context, provider *models.Provider) (models.Versions, error) { var mirrorData struct { Versions map[string]struct{} `json:"versions"` } filename := filepath.Join(provider.RegistryName, provider.Namespace, provider.Name, "index.json") if err := handler.readMirrorData(filename, &mirrorData); err != nil { return nil, err } var versions = make(models.Versions, 0, len(mirrorData.Versions)) for version := range mirrorData.Versions { versions = append(versions, &models.Version{ Version: version, Platforms: availablePlatforms, }) } return versions, nil } // GetPlatform implements ProviderHandler.GetPlatform func (handler *FilesystemMirrorProviderHandler) GetPlatform(_ context.Context, provider *models.Provider) (*models.ResponseBody, error) { var mirrorData struct { Archives map[string]struct { URL string `json:"url"` Hashes []string `json:"hashes"` } `json:"archives"` } filename := filepath.Join(provider.RegistryName, provider.Namespace, provider.Name, provider.Version+".json") if err := handler.readMirrorData(filename, &mirrorData); err != nil { return nil, err } var resp *models.ResponseBody if archive, ok := mirrorData.Archives[provider.Platform()]; ok { // check if the URL contains http scheme, it may just be a filename and we need to build the URL if !strings.Contains(archive.URL, "://") { archive.URL = filepath.Join(handler.filesystemMirrorPath, provider.RegistryName, provider.Namespace, provider.Name, archive.URL) } resp = &models.ResponseBody{ Filename: filepath.Base(archive.URL), DownloadURL: archive.URL, } } return resp, nil } func (handler *FilesystemMirrorProviderHandler) readMirrorData(filename string, value any) error { filename = filepath.Join(handler.filesystemMirrorPath, filename) if !util.FileExists(filename) { return nil } data, err := os.ReadFile(filename) if err != nil { return errors.New(err) } if err := json.Unmarshal(data, value); err != nil { return errors.New(err) } return nil } ================================================ FILE: internal/tf/cache/handlers/network_mirror_provider.go ================================================ package handlers import ( "context" "fmt" "net/http" "net/url" "path" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cache/helpers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" ) var _ ProviderHandler = new(NetworkMirrorProviderHandler) type NetworkMirrorProviderHandler struct { *CommonProviderHandler client *helpers.Client networkMirrorURL *url.URL } func NewNetworkMirrorProviderHandler(logger log.Logger, networkMirror *cliconfig.ProviderInstallationNetworkMirror, credsSource *cliconfig.CredentialsSource) (*NetworkMirrorProviderHandler, error) { networkMirrorURL, err := url.Parse(networkMirror.URL) if err != nil { return nil, errors.Errorf("failed to parse network mirror URL %q: %w", networkMirror.URL, err) } return &NetworkMirrorProviderHandler{ CommonProviderHandler: NewCommonProviderHandler(logger, networkMirror.Include, networkMirror.Exclude), client: helpers.NewClient(credsSource), networkMirrorURL: networkMirrorURL, }, nil } func (handler *NetworkMirrorProviderHandler) String() string { return "network_mirror '" + handler.networkMirrorURL.String() + "'" } // GetVersions implements ProviderHandler.GetVersions func (handler *NetworkMirrorProviderHandler) GetVersions(ctx context.Context, provider *models.Provider) (models.Versions, error) { var mirrorData struct { Versions map[string]struct{} `json:"versions"` } reqPath := path.Join(provider.RegistryName, provider.Namespace, provider.Name, "index.json") reqURL := fmt.Sprintf("%s/%s", strings.TrimRight(handler.networkMirrorURL.String(), "/"), reqPath) if err := handler.client.Do(ctx, http.MethodGet, reqURL, &mirrorData); err != nil { return nil, err } var versions = make(models.Versions, 0, len(mirrorData.Versions)) for version := range mirrorData.Versions { versions = append(versions, &models.Version{ Version: version, Platforms: availablePlatforms, }) } return versions, nil } // GetPlatform implements ProviderHandler.GetPlatform func (handler *NetworkMirrorProviderHandler) GetPlatform(ctx context.Context, provider *models.Provider) (*models.ResponseBody, error) { var mirrorData struct { Archives map[string]struct { URL string `json:"url"` Hashes []string `json:"hashes"` } `json:"archives"` } reqPath := path.Join(provider.RegistryName, provider.Namespace, provider.Name, provider.Version+".json") reqURL := fmt.Sprintf("%s/%s", strings.TrimRight(handler.networkMirrorURL.String(), "/"), reqPath) if err := handler.client.Do(ctx, http.MethodGet, reqURL, &mirrorData); err != nil { return nil, err } var resp *models.ResponseBody if archive, ok := mirrorData.Archives[provider.Platform()]; ok { resp = (&models.ResponseBody{ Filename: filepath.Base(archive.URL), DownloadURL: archive.URL, }).ResolveRelativeReferences(handler.networkMirrorURL.ResolveReference(&url.URL{ Path: path.Join(handler.networkMirrorURL.Path, provider.Address()), })) } return resp, nil } ================================================ FILE: internal/tf/cache/handlers/provider.go ================================================ // Package handlers provides the interfaces and common implementations for handling provider requests. package handlers import ( "context" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" ) var availablePlatforms []*models.Platform = []*models.Platform{ {OS: "solaris", Arch: "amd64"}, {OS: "openbsd", Arch: "386"}, {OS: "openbsd", Arch: "arm"}, {OS: "openbsd", Arch: "amd64"}, {OS: "freebsd", Arch: "386"}, {OS: "freebsd", Arch: "arm"}, {OS: "freebsd", Arch: "amd64"}, {OS: "linux", Arch: "386"}, {OS: "linux", Arch: "arm"}, {OS: "linux", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}, {OS: "windows", Arch: "386"}, {OS: "windows", Arch: "amd64"}, } // ProviderHandlers is a slice of ProviderHandler. type ProviderHandlers []ProviderHandler func NewProviderHandlers(cliCfg *cliconfig.Config, logger log.Logger, registryNames []string) (ProviderHandlers, error) { var ( providerHandlers = make([]ProviderHandler, 0, len(cliCfg.ProviderInstallation.Methods)) excludeAddrs = make([]string, 0, len(cliCfg.ProviderInstallation.Methods)) directIsDefined bool ) for _, registryName := range registryNames { excludeAddrs = append(excludeAddrs, registryName+"/*/*") } for _, method := range cliCfg.ProviderInstallation.Methods { switch method := method.(type) { case *cliconfig.ProviderInstallationFilesystemMirror: providerHandlers = append(providerHandlers, NewFilesystemMirrorProviderHandler(logger, method)) case *cliconfig.ProviderInstallationNetworkMirror: networkMirrorHandler, err := NewNetworkMirrorProviderHandler(logger, method, cliCfg.CredentialsSource()) if err != nil { return nil, err } providerHandlers = append(providerHandlers, networkMirrorHandler) case *cliconfig.ProviderInstallationDirect: providerHandlers = append(providerHandlers, NewDirectProviderHandler(logger, method, cliCfg.CredentialsSource())) directIsDefined = true } method.AppendExclude(excludeAddrs) } if !directIsDefined { // In a case if none of direct provider installation methods `cliCfg.ProviderInstallation.Methods` are specified. providerHandlers = append(providerHandlers, NewDirectProviderHandler(logger, new(cliconfig.ProviderInstallationDirect), cliCfg.CredentialsSource())) } return providerHandlers, nil } // DiscoveryURL looks for the first handler that can handle the given `registryName`, // which is determined by the include and exclude settings in the `.terraformrc` CLI config file. // If the handler is found, tries to discover its API endpoints otherwise return the default registry URLs. func (handlers ProviderHandlers) DiscoveryURL(ctx context.Context, registryName string) (*RegistryURLs, error) { provider := models.ParseProvider(registryName) for _, handler := range handlers { if handler.CanHandleProvider(provider) { return handler.DiscoveryURL(ctx, registryName) } } return DefaultRegistryURLs, nil } type ProviderHandler interface { // CanHandleProvider returns true if the given provider can be handled by this handler. CanHandleProvider(provider *models.Provider) bool // GetVersions serves a request that returns all versions for a single provider. GetVersions(ctx context.Context, provider *models.Provider) (models.Versions, error) // GetPlatform serves a request that returns a provider for a specific platform. GetPlatform(ctx context.Context, provider *models.Provider) (*models.ResponseBody, error) // DiscoveryURL discovers modules and providers API endpoints for the specified `registryName`. // https://developer.hashicorp.com/terraform/internals/remote-service-discovery#discovery-process DiscoveryURL(ctx context.Context, registryName string) (*RegistryURLs, error) } ================================================ FILE: internal/tf/cache/handlers/provider_test.go ================================================ package handlers_test import ( "context" "errors" "net" "net/url" "syscall" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsOfflineError(t *testing.T) { t.Parallel() testCases := []struct { err error desc string expected bool }{ // *url.Error wrapping various transport failures — all caught by the single *url.Error check. {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: syscall.ECONNREFUSED}, desc: "connection refused", expected: true}, {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: syscall.ECONNRESET}, desc: "connection reset", expected: true}, {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: syscall.ENETUNREACH}, desc: "network unreachable", expected: true}, {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: &net.DNSError{Err: "no such host", Name: "registry.terraform.io", IsNotFound: true}}, desc: "DNS not found", expected: true}, {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: &net.DNSError{Err: "server misbehaving", Name: "blocked-registry.invalid"}}, desc: "DNS temporary failure", expected: true}, {err: &url.Error{Op: "Get", URL: "https://registry.terraform.io/.well-known/terraform.json", Err: errors.New("tls: failed to verify certificate")}, desc: "TLS error", expected: true}, // Non-transport errors — should NOT be treated as offline. {err: errors.New("random error"), desc: "a random error that should not be offline", expected: false}, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { t.Parallel() result := handlers.IsOfflineError(tc.err) assert.Equal(t, tc.expected, result, "Expected result for %v is %v", tc.desc, tc.expected) }) } } // TestProviderHandlers_DiscoveryURL_WithNetworkMirrorForBlockedRegistry reproduces the bug from issue #5613. // When a network_mirror is configured for a registry that is unreachable (e.g., blocked by DNS), // DiscoveryURL should return DefaultRegistryURLs without attempting to contact the registry. // The ".invalid" TLD is guaranteed to never resolve per RFC 2606. func TestProviderHandlers_DiscoveryURL_WithNetworkMirrorForBlockedRegistry(t *testing.T) { t.Parallel() cfg := &cliconfig.Config{ ProviderInstallation: &cliconfig.ProviderInstallation{ Methods: cliconfig.ProviderInstallationMethods{ cliconfig.NewProviderInstallationNetworkMirror( "https://mirror.example.com/providers/", []string{"blocked-registry.invalid/*/*"}, nil, ), }, }, } providerHandlers, err := handlers.NewProviderHandlers(cfg, log.New(), nil) require.NoError(t, err) urls, err := providerHandlers.DiscoveryURL(context.Background(), "blocked-registry.invalid") require.NoError(t, err) assert.Equal(t, handlers.DefaultRegistryURLs, urls) } ================================================ FILE: internal/tf/cache/handlers/proxy_provider.go ================================================ package handlers import ( "encoding/json" "net/http" "net/url" "path" "path/filepath" "strconv" "strings" "github.com/gruntwork-io/terragrunt/internal/tf/cache/helpers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cache/router" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/labstack/echo/v4" ) const ( // Provider's assets consist of three files/URLs: zipped binary, hashes and signature ProviderDownloadURLName providerURLName = "download_url" ProviderSHASumsURLName providerURLName = "shasums_url" ProviderSHASumsSignatureURLName providerURLName = "shasums_signature_url" ) var ( // providerURLNames contains urls that must be modified to forward terraform requests through this server. providerURLNames = []providerURLName{ ProviderDownloadURLName, ProviderSHASumsURLName, ProviderSHASumsSignatureURLName, } ) type providerURLName string type ProxyProviderHandler struct { *CommonProviderHandler *helpers.ReverseProxy } func NewProxyProviderHandler(logger log.Logger, credsSource *cliconfig.CredentialsSource) *ProxyProviderHandler { return &ProxyProviderHandler{ CommonProviderHandler: NewCommonProviderHandler(logger, nil, nil), ReverseProxy: &helpers.ReverseProxy{CredsSource: credsSource, Logger: logger}, } } func (handler *ProxyProviderHandler) String() string { return "proxy" } // GetVersions implements ProviderHandler.GetVersions // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider // //nolint:lll func (handler *ProxyProviderHandler) GetVersions(ctx echo.Context, provider *models.Provider) error { apiURLs, err := handler.DiscoveryURL(ctx.Request().Context(), provider.RegistryName) if err != nil { return err } reqURL := &url.URL{ Scheme: "https", Host: provider.RegistryName, Path: path.Join(apiURLs.ProvidersV1, provider.Namespace, provider.Name, "versions"), } return handler.NewRequest(ctx, reqURL) } // GetPlatform implements ProviderHandler.GetPlatform func (handler *ProxyProviderHandler) GetPlatform(ctx echo.Context, provider *models.Provider, downloaderController router.Controller) error { apiURLs, err := handler.DiscoveryURL(ctx.Request().Context(), provider.RegistryName) if err != nil { return err } platformURL := &url.URL{ Scheme: "https", Host: provider.RegistryName, Path: path.Join(apiURLs.ProvidersV1, provider.Namespace, provider.Name, provider.Version, "download", provider.OS, provider.Arch), } return handler.ReverseProxy. WithModifyResponse(func(resp *http.Response) error { return modifyDownloadURLsInJSONBody(resp, downloaderController) }). NewRequest(ctx, platformURL) } // Download implements ProviderHandler.Download func (handler *ProxyProviderHandler) Download(ctx echo.Context, provider *models.Provider) error { // check if the URL contains http scheme, it may just be a filename and we need to build the URL if !strings.Contains(provider.DownloadURL, "://") { apiURLs, err := handler.DiscoveryURL(ctx.Request().Context(), provider.RegistryName) if err != nil { return err } downloadURL := &url.URL{ Scheme: "https", Host: provider.RegistryName, Path: filepath.Join(apiURLs.ProvidersV1, provider.RegistryName, provider.Namespace, provider.Name, provider.DownloadURL), } return handler.NewRequest(ctx, downloadURL) } downloadURL, err := url.Parse(provider.DownloadURL) if err != nil { return err } return handler.NewRequest(ctx, downloadURL) } // modifyDownloadURLsInJSONBody modifies the response to redirect the download URLs to the local server. func modifyDownloadURLsInJSONBody(resp *http.Response, downloaderController router.Controller) error { var data map[string]json.RawMessage return helpers.ModifyJSONBody(resp, &data, func() error { for _, name := range providerURLNames { linkBytes, ok := data[string(name)] if !ok || linkBytes == nil { continue } link := string(linkBytes) link, err := strconv.Unquote(link) if err != nil { return err } linkURL, err := url.Parse(link) if err != nil { return err } // Modify link to http://{localhost_host}/downloads/provider/{remote_host}/{remote_path} linkURL.Path = path.Join(downloaderController.URL().Path, linkURL.Host, linkURL.Path) linkURL.Scheme = downloaderController.URL().Scheme linkURL.Host = downloaderController.URL().Host link = strconv.Quote(linkURL.String()) data[string(name)] = []byte(link) } return nil }) } ================================================ FILE: internal/tf/cache/handlers/registry_urls.go ================================================ package handlers import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "github.com/gruntwork-io/terragrunt/internal/errors" ) const ( // well-known address for discovery URLs wellKnownURL = ".well-known/terraform.json" ) var ( DefaultRegistryURLs = &RegistryURLs{ ModulesV1: "/v1/modules", ProvidersV1: "/v1/providers", } ) type RegistryURLs struct { ModulesV1 string `json:"modules.v1"` ProvidersV1 string `json:"providers.v1"` } func (urls *RegistryURLs) String() string { if b, err := json.Marshal(urls); err == nil { return string(b) } return fmt.Sprintf("%v, %v", urls.ModulesV1, urls.ProvidersV1) } func DiscoveryURL(ctx context.Context, registryName string) (*RegistryURLs, error) { url := fmt.Sprintf("https://%s/%s", registryName, wellKnownURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, errors.New(err) } resp, err := (&http.Client{}).Do(req) if err != nil { return nil, errors.New(err) } defer resp.Body.Close() //nolint:errcheck switch resp.StatusCode { case http.StatusNotFound, http.StatusInternalServerError: return nil, errors.New(NotFoundWellKnownURLError{wellKnownURL}) case http.StatusOK: default: return nil, fmt.Errorf("%s returned %s", url, resp.Status) } content, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.New(err) } urls := new(RegistryURLs) if err := json.Unmarshal(content, urls); err != nil { return nil, errors.New(err) } return urls, nil } // IsOfflineError returns true if the given error indicates that the registry // could not be reached, and default URLs should be used instead. func IsOfflineError(err error) bool { if errors.As(err, &NotFoundWellKnownURLError{}) { return true } var urlErr *url.Error return errors.As(err, &urlErr) } ================================================ FILE: internal/tf/cache/helpers/client.go ================================================ package helpers import ( "context" "encoding/json" "io" "net/http" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" svchost "github.com/hashicorp/terraform-svchost" "github.com/puzpuzpuz/xsync/v3" ) // Client is an HTTP client. type Client struct { *http.Client credsSource *cliconfig.CredentialsSource cache *xsync.MapOf[string, []byte] } func NewClient(credsSource *cliconfig.CredentialsSource) *Client { return &Client{ Client: &http.Client{}, credsSource: credsSource, cache: xsync.NewMapOf[string, []byte](), } } // Do sends an HTTP request and decodes an HTTP response to the given `value`. func (client *Client) Do(ctx context.Context, method, reqURL string, value any) error { if bodyBytes, ok := client.cache.Load(reqURL); ok { return unmarshalBody(bodyBytes, value) } req, err := http.NewRequestWithContext(ctx, method, reqURL, nil) if err != nil { return errors.New(err) } if client.credsSource != nil { hostname := svchost.Hostname(req.URL.Hostname()) if creds := client.credsSource.ForHost(hostname); creds != nil { creds.PrepareRequest(req) } } resp, err := client.Client.Do(req) if err != nil { return errors.New(err) } defer resp.Body.Close() //nolint:errcheck bodyBytes, err := decodeResponse(resp) if err != nil { return errors.New(err) } client.cache.Store(reqURL, bodyBytes) return unmarshalBody(bodyBytes, value) } func unmarshalBody(data []byte, value any) error { if data == nil { return nil } if err := json.Unmarshal(data, value); err != nil { return errors.New(err) } return nil } func decodeResponse(resp *http.Response) ([]byte, error) { if resp.StatusCode != http.StatusOK { return nil, nil } buffer, err := ResponseBuffer(resp) if err != nil { return nil, err } bodyBytes, err := io.ReadAll(buffer) if err != nil { return nil, errors.New(err) } resp.Body = io.NopCloser(buffer) return bodyBytes, nil } ================================================ FILE: internal/tf/cache/helpers/http.go ================================================ // Package helpers provides utility functions for working with HTTP requests and responses. package helpers import ( "bytes" "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) func Fetch(ctx context.Context, req *http.Request, dst io.Writer) error { req.Header.Add("Accept-Encoding", "gzip") resp, err := (&http.Client{}).Do(req) if err != nil { return errors.New(err) } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s returned from %s", resp.Status, req.URL) } reader, err := ResponseReader(resp) if err != nil { return err } if written, err := util.Copy(ctx, dst, reader); err != nil { return errors.New(err) } else if resp.ContentLength != -1 && written != resp.ContentLength { return errors.Errorf("incorrect response size: expected %d bytes, but got %d bytes", resp.ContentLength, written) } return nil } // FetchToFile downloads the file from the given `url` into the specified `dst` file. func FetchToFile(ctx context.Context, req *http.Request, dst string) error { file, err := os.Create(dst) if err != nil { return errors.New(err) } defer file.Close() //nolint:errcheck if err := Fetch(ctx, req, file); err != nil { return err } if err := file.Sync(); err != nil { return errors.New(err) } return nil } func ResponseReader(resp *http.Response) (io.ReadCloser, error) { // Check that the server actually sent compressed data switch resp.Header.Get("Content-Encoding") { case "gzip": reader, err := gzip.NewReader(resp.Body) if err != nil { return nil, err } resp.Header.Del("Content-Encoding") resp.Header.Del("Content-Length") resp.ContentLength = -1 resp.Uncompressed = true return reader, nil default: return resp.Body, nil } } func ResponseBuffer(resp *http.Response) (*bytes.Buffer, error) { reader, err := ResponseReader(resp) if err != nil { return nil, err } defer reader.Close() //nolint:errcheck buffer := new(bytes.Buffer) if _, err := buffer.ReadFrom(reader); err != nil { return nil, errors.New(err) } return buffer, nil } func ModifyJSONBody(resp *http.Response, value any, fn func() error) error { if resp.StatusCode != http.StatusOK { return nil } buffer, err := ResponseBuffer(resp) if err != nil { return err } decoder := json.NewDecoder(buffer) if err := decoder.Decode(value); err != nil { return errors.New(err) } if fn == nil { return nil } if err := fn(); err != nil { return err } encoder := json.NewEncoder(buffer) if err := encoder.Encode(value); err != nil { return errors.New(err) } resp.Body = io.NopCloser(buffer) resp.ContentLength = int64(buffer.Len()) resp.Header.Set("Content-Length", strconv.Itoa(buffer.Len())) return nil } ================================================ FILE: internal/tf/cache/helpers/reverse_proxy.go ================================================ package helpers import ( "net/http" "net/http/httputil" "net/url" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/pkg/log" svchost "github.com/hashicorp/terraform-svchost" "github.com/labstack/echo/v4" ) type ReverseProxy struct { ServerURL *url.URL CredsSource *cliconfig.CredentialsSource Rewrite func(*httputil.ProxyRequest) ModifyResponse func(resp *http.Response) error ErrorHandler func(http.ResponseWriter, *http.Request, error) Logger log.Logger } func (reverseProxy ReverseProxy) WithModifyResponse(fn func(resp *http.Response) error) *ReverseProxy { reverseProxy.ModifyResponse = fn return &reverseProxy } func (reverseProxy *ReverseProxy) NewRequest(ctx echo.Context, targetURL *url.URL) (er error) { proxy := &httputil.ReverseProxy{ Rewrite: func(req *httputil.ProxyRequest) { req.Out.Host = targetURL.Host req.Out.URL = targetURL if reverseProxy.CredsSource != nil { hostname := svchost.Hostname(req.Out.URL.Hostname()) if creds := reverseProxy.CredsSource.ForHost(hostname); creds != nil { creds.PrepareRequest(req.Out) } } if reverseProxy.Rewrite != nil { reverseProxy.Rewrite(req) } }, ModifyResponse: func(resp *http.Response) error { if reverseProxy.ModifyResponse != nil { return reverseProxy.ModifyResponse(resp) } return nil }, ErrorHandler: func(resp http.ResponseWriter, req *http.Request, err error) { reverseProxy.Logger.Errorf("remote %s unreachable, could not forward: %v", targetURL, err) ctx.Error(echo.NewHTTPError(http.StatusServiceUnavailable)) if reverseProxy.ErrorHandler != nil { reverseProxy.ErrorHandler(resp, req, err) } }, } proxy.ServeHTTP(ctx.Response(), ctx.Request()) return nil } ================================================ FILE: internal/tf/cache/middleware/key_auth.go ================================================ package middleware import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) type Authorization struct { Token string } // Validator validates tokens. // // To enhance security, we use token-based authentication to connect to the cache server in order to prevent unauthorized connections from third-party applications. // Currently, the cache server only supports `x-api-key` token, the value of which can be any text. func (auth *Authorization) Validator(bearerToken string, ctx echo.Context) (bool, error) { if bearerToken != auth.Token { return false, errors.Errorf("Authorization: token either expired or inexistent") } return true, nil } // KeyAuth returns an KeyAuth middleware. func KeyAuth(token string) echo.MiddlewareFunc { auth := Authorization{ Token: token, } return middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ Skipper: middleware.DefaultSkipper, KeyLookup: "header:" + echo.HeaderAuthorization, AuthScheme: "Bearer", Validator: auth.Validator, }) } ================================================ FILE: internal/tf/cache/middleware/logger.go ================================================ package middleware import ( "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) func Logger(logger log.Logger) echo.MiddlewareFunc { return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ LogStatus: true, LogURI: true, LogError: true, HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code LogValuesFunc: func(_ echo.Context, req middleware.RequestLoggerValues) error { logger := logger. WithField(placeholders.CacheServerURLKeyName, req.URI). WithField(placeholders.CacheServerStatusKeyName, req.Status) if req.Error != nil { logger.Errorf("Cache server was unable to process the received request, %s", req.Error.Error()) } else { logger.Tracef("Cache server received request") } return nil }, }) } ================================================ FILE: internal/tf/cache/middleware/package.go ================================================ // Package middleware provides a set of middleware for the Terragrunt provider cache server. package middleware ================================================ FILE: internal/tf/cache/middleware/recover.go ================================================ package middleware import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/labstack/echo/v4" ) func Recover(logger log.Logger) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) (er error) { defer errors.Recover(func(err error) { logger.Debug(errors.ErrorStack(err)) er = err }) return next(ctx) } } } ================================================ FILE: internal/tf/cache/models/helper.go ================================================ package models import ( "net/url" "path" "strings" ) func resolveRelativeReference(base *url.URL, link string) string { if link == "" { return link } if strings.Contains(link, "://") { return link } if strings.HasPrefix(link, "/") { return (&url.URL{ Scheme: base.Scheme, Host: base.Host, Path: link, }).String() } return base.ResolveReference( &url.URL{ Path: path.Join( base.Path, link, ), }, ).String() } ================================================ FILE: internal/tf/cache/models/provider.go ================================================ // Package models provides the data structures used to represent Terraform providers and their details. package models import ( "fmt" "net/url" "path" "strings" goversion "github.com/hashicorp/go-version" ) type Providers []*Provider func ParseProviders(strs ...string) Providers { var prvoiders Providers for _, str := range strs { if provider := ParseProvider(str); provider != nil { prvoiders = append(prvoiders, provider) } } return prvoiders } func (providers Providers) Find(target *Provider) *Provider { for _, provider := range providers { if provider.Match(target) { return provider } } return nil } // SigningKey represents a key used to sign packages from a registry, along with an optional trust signature from the registry operator. These are both in ASCII armored OpenPGP format. type SigningKey struct { ASCIIArmor string `json:"ascii_armor"` TrustSignature string `json:"trust_signature"` } type SigningKeyList struct { GPGPublicKeys []*SigningKey `json:"gpg_public_keys"` } func (list SigningKeyList) Keys() map[string]string { keys := make(map[string]string) for _, key := range list.GPGPublicKeys { keys[key.ASCIIArmor] = key.TrustSignature } return keys } type Versions []*Version type Version struct { Version string `json:"version"` Protocols []string `json:"protocols"` Platforms Platforms `json:"platforms"` } func (version Version) String() string { return fmt.Sprintf("%s/%s/%s", version.Version, version.Protocols, version.Platforms) } // FilterValid returns only versions with valid semver strings that conform to // the Terraform registry protocol (no "v" prefix, no empty strings). // The second return value contains the invalid version strings that were filtered out. func (versions Versions) FilterValid() (Versions, []string) { valid := make(Versions, 0, len(versions)) invalid := make([]string, 0, len(versions)) for _, v := range versions { if v.Version == "" || strings.HasPrefix(v.Version, "v") { invalid = append(invalid, v.Version) continue } if _, err := goversion.NewVersion(v.Version); err != nil { invalid = append(invalid, v.Version) continue } valid = append(valid, v) } return valid, invalid } type Platforms []*Platform type Platform struct { OS string `json:"os"` Arch string `json:"arch"` } func (platform Platform) String() string { return fmt.Sprintf("%s/%s", platform.OS, platform.Arch) } // ResponseBody represents the details of the Terraform provider received from a registry. type ResponseBody struct { Platform Protocols []string `json:"protocols,omitempty"` Filename string `json:"filename"` DownloadURL string `json:"download_url"` SHA256SumsURL string `json:"shasums_url,omitempty"` SHA256SumsSignatureURL string `json:"shasums_signature_url,omitempty"` SHA256Sum string `json:"shasum,omitempty"` SigningKeys SigningKeyList `json:"signing_keys"` } func (body *ResponseBody) ResolveRelativeReferences(base *url.URL) *ResponseBody { clone := *body clone.DownloadURL = resolveRelativeReference(base, body.DownloadURL) clone.SHA256SumsSignatureURL = resolveRelativeReference(base, body.SHA256SumsSignatureURL) clone.SHA256SumsURL = resolveRelativeReference(base, body.SHA256SumsURL) return &clone } // Provider represents the details of the Terraform provider. type Provider struct { *ResponseBody RegistryName string Namespace string Name string Version string OS string Arch string // OriginalConstraints holds the version constraints from the module's required_providers block OriginalConstraints string } func ParseProvider(str string) *Provider { parts := strings.Split(str, "/") for i := range parts { if parts[i] == "*" { parts[i] = "" } } const twoVals = 2 switch { case len(parts) == twoVals: return &Provider{ Namespace: parts[0], Name: parts[1], } case len(parts) > twoVals: return &Provider{ RegistryName: parts[0], Namespace: parts[1], Name: parts[2], } } return &Provider{ RegistryName: parts[0], } } func (provider *Provider) String() string { if provider.Version != "" { return fmt.Sprintf("%s/%s/%s v%s", provider.RegistryName, provider.Namespace, provider.Name, provider.Version) } return fmt.Sprintf("%s/%s/%s", provider.RegistryName, provider.Namespace, provider.Name) } func (provider *Provider) Platform() string { return fmt.Sprintf("%s_%s", provider.OS, provider.Arch) } func (provider *Provider) Address() string { return path.Join(provider.RegistryName, provider.Namespace, provider.Name) } func (provider *Provider) Constraints() string { return provider.OriginalConstraints } // Match returns true if all defined provider properties are matched. func (provider *Provider) Match(target *Provider) bool { registryNameMatch := provider.RegistryName == "" || target.RegistryName == "" || provider.RegistryName == target.RegistryName namespaceMatch := provider.Namespace == "" || target.Namespace == "" || provider.Namespace == target.Namespace nameMatch := provider.Name == "" || target.Name == "" || provider.Name == target.Name osMatch := provider.OS == "" || target.OS == "" || provider.OS == target.OS archMatch := provider.Arch == "" || target.Arch == "" || provider.Arch == target.Arch downloadURLMatch := provider.ResponseBody == nil || provider.DownloadURL == "" || target.DownloadURL == "" || provider.DownloadURL == target.DownloadURL if registryNameMatch && namespaceMatch && nameMatch && osMatch && archMatch && downloadURLMatch { return true } return false } ================================================ FILE: internal/tf/cache/models/provider_test.go ================================================ package models_test import ( "fmt" "net/url" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFilterValid(t *testing.T) { t.Parallel() testCases := []struct { name string input models.Versions expectedValid []string expectedInvalid []string }{ { name: "all valid versions", input: models.Versions{ {Version: "1.0.0"}, {Version: "2.5.2"}, {Version: "0.1.0-beta1"}, }, expectedValid: []string{"1.0.0", "2.5.2", "0.1.0-beta1"}, expectedInvalid: []string{}, }, { name: "v-prefixed versions are filtered", input: models.Versions{ {Version: "1.0.0"}, {Version: "v2.5.3"}, {Version: "v1.0.0"}, }, expectedValid: []string{"1.0.0"}, expectedInvalid: []string{"v2.5.3", "v1.0.0"}, }, { name: "empty strings are filtered", input: models.Versions{ {Version: "1.0.0"}, {Version: ""}, {Version: "2.0.0"}, }, expectedValid: []string{"1.0.0", "2.0.0"}, expectedInvalid: []string{""}, }, { name: "garbage strings are filtered", input: models.Versions{ {Version: "1.0.0"}, {Version: "not-a-version"}, {Version: "latest"}, }, expectedValid: []string{"1.0.0"}, expectedInvalid: []string{"not-a-version", "latest"}, }, { name: "mixed valid and invalid", input: models.Versions{ {Version: "1.0.0"}, {Version: "v2.5.3-alpha1"}, {Version: ""}, {Version: "3.1.4"}, {Version: "not-a-version"}, {Version: "0.1.0-beta1"}, }, expectedValid: []string{"1.0.0", "3.1.4", "0.1.0-beta1"}, expectedInvalid: []string{"v2.5.3-alpha1", "", "not-a-version"}, }, { name: "all invalid", input: models.Versions{{Version: "v1.0.0"}, {Version: ""}, {Version: "bad"}}, expectedValid: []string{}, expectedInvalid: []string{"v1.0.0", "", "bad"}, }, { name: "empty input", input: models.Versions{}, expectedValid: []string{}, expectedInvalid: []string{}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() valid, invalid := tc.input.FilterValid() validStrs := make([]string, 0, len(valid)) for _, v := range valid { validStrs = append(validStrs, v.Version) } assert.Equal(t, tc.expectedValid, validStrs) assert.Equal(t, tc.expectedInvalid, invalid) }) } } func TestResolveRelativeReferences(t *testing.T) { t.Parallel() testCases := []struct { baseURL string body models.ResponseBody expectedResolved models.ResponseBody }{ { "https://releases.hashicorp.com/terraform-provider-local/2.5.1", models.ResponseBody{ DownloadURL: "terraform-provider-local_2.5.1_darwin_amd64.zip", SHA256SumsURL: "terraform-provider-local_2.5.1_SHA256SUMS", SHA256SumsSignatureURL: "terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig", }, models.ResponseBody{ DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip", SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS", SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig", }, }, { "https://somehost.com", models.ResponseBody{ DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip", SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS", SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig", }, models.ResponseBody{ DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip", SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS", SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig", }, }, { "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64", models.ResponseBody{ DownloadURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider.zip", SHA256SumsURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS", SHA256SumsSignatureURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS.sig", }, models.ResponseBody{ DownloadURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider.zip", SHA256SumsURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS", SHA256SumsSignatureURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS.sig", }, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() baseURL, err := url.Parse(tc.baseURL) require.NoError(t, err) actualResolved := tc.body.ResolveRelativeReferences(baseURL) assert.Equal(t, tc.expectedResolved, *actualResolved) }) } } ================================================ FILE: internal/tf/cache/router/controller.go ================================================ package router import "net/url" // Controller is an interface implemented by a REST controller type Controller interface { // Register is the method called by the router, passing the router // groups to let the controller register its methods Register(router *Router) // URL returns the controller fqdn. URL() *url.URL } ================================================ FILE: internal/tf/cache/router/router.go ================================================ // Package router provides a simple wrapper around the echo framework to create a REST API. package router import ( "net/url" "path" "strings" "github.com/labstack/echo/v4" ) type Router struct { *echo.Echo // urlPath is the router urlPath urlPath string } func New() *Router { return &Router{ Echo: echo.New(), urlPath: "/", } } func (router *Router) Group(urlPath string) *Router { return &Router{ Echo: router.Echo, urlPath: path.Join(router.urlPath, urlPath), } } func (router *Router) URL() *url.URL { return &url.URL{ Scheme: "http", Host: router.Server.Addr, Path: router.urlPath, } } // Register registers controller's endpoints func (router *Router) Register(controllers ...Controller) { for _, controller := range controllers { controller.Register(router) } } // Use adds middleware to the chain which is run after router. func (router *Router) Use(middlewares ...echo.MiddlewareFunc) { for _, middleware := range middlewares { middleware := func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { if strings.HasPrefix(strings.Trim(ctx.Path(), "/"), strings.Trim(router.urlPath, "/")) { return middleware(next)(ctx) } return next(ctx) } } router.Echo.Use(middleware) } } // GET registers a new GET route for a path with matching handler in the router // with optional route-level middleware. func (router *Router) GET(urlPath string, handle echo.HandlerFunc) { router.Echo.GET(path.Join(router.urlPath, urlPath), handle) } // HEAD registers a new HEAD route for a path with matching handler in the // router with optional route-level middleware. func (router *Router) HEAD(urlPath string, handle echo.HandlerFunc) { router.Echo.HEAD(path.Join(router.urlPath, urlPath), handle) } // OPTIONS registers a new OPTIONS route for a path with matching handler in the // router with optional route-level middleware. func (router *Router) OPTIONS(urlPath string, handle echo.HandlerFunc) { router.Echo.OPTIONS(path.Join(router.urlPath, urlPath), handle) } // POST registers a new POST route for a path with matching handler in the // router with optional route-level middleware. func (router *Router) POST(urlPath string, handle echo.HandlerFunc) { router.Echo.POST(path.Join(router.urlPath, urlPath), handle) } // PUT registers a new PUT route for a path with matching handler in the // router with optional route-level middleware. func (router *Router) PUT(urlPath string, handle echo.HandlerFunc) { router.Echo.PUT(path.Join(router.urlPath, urlPath), handle) } // PATCH registers a new PATCH route for a path with matching handler in the // router with optional route-level middleware. func (router *Router) PATCH(urlPath string, handle echo.HandlerFunc) { router.Echo.PATCH(path.Join(router.urlPath, urlPath), handle) } // DELETE registers a new DELETE route for a path with matching handler in the router // with optional route-level middleware. func (router *Router) DELETE(urlPath string, handle echo.HandlerFunc) { router.Echo.DELETE(path.Join(router.urlPath, urlPath), handle) } ================================================ FILE: internal/tf/cache/server.go ================================================ // Package cache provides a private OpenTofu/Terraform provider cache server. package cache import ( "context" "net" "net/http" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cache/controllers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/middleware" "github.com/gruntwork-io/terragrunt/internal/tf/cache/router" "github.com/gruntwork-io/terragrunt/internal/tf/cache/services" "golang.org/x/sync/errgroup" ) // Server is a private Terraform cache for provider caching. type Server struct { *router.Router *Config ProviderController *controllers.ProviderController services []services.Service } // NewServer returns a new Server instance. func NewServer(opts ...Option) *Server { cfg := NewConfig(opts...) authMiddleware := middleware.KeyAuth(cfg.token) downloaderController := &controllers.DownloaderController{ ProxyProviderHandler: cfg.proxyProviderHandler, ProviderService: cfg.providerService, } providerController := &controllers.ProviderController{ AuthMiddleware: authMiddleware, DownloaderController: downloaderController, ProviderHandlers: cfg.providerHandlers, ProxyProviderHandler: cfg.proxyProviderHandler, ProviderService: cfg.providerService, CacheProviderHTTPStatusCode: cfg.cacheProviderHTTPStatusCode, Logger: cfg.logger, } discoveryController := &controllers.DiscoveryController{ Endpointers: []controllers.Endpointer{providerController}, } rootRouter := router.New() rootRouter.Use(middleware.Logger(cfg.logger)) rootRouter.Use(middleware.Recover(cfg.logger)) rootRouter.Register(discoveryController, downloaderController) v1Group := rootRouter.Group("v1") v1Group.Register(providerController) return &Server{ Router: rootRouter, Config: cfg, services: []services.Service{cfg.providerService}, ProviderController: providerController, } } // DiscoveryURL looks for the first handler that can handle the given `registryName`, // which is determined by the include and exclude settings in the `.terraformrc` CLI config file. // If the handler is found, tries to discover its API endpoints otherwise return the default registry URLs. func (server *Server) DiscoveryURL(ctx context.Context, registryName string) (*handlers.RegistryURLs, error) { return server.providerHandlers.DiscoveryURL(ctx, registryName) } // Listen starts listening to the given configuration address. It also automatically chooses a free port if not explicitly specified. func (server *Server) Listen(ctx context.Context) (net.Listener, error) { lc := &net.ListenConfig{} ln, err := lc.Listen(ctx, "tcp", server.Addr()) if err != nil { return nil, errors.New(err) } server.Server.Addr = ln.Addr().String() server.logger.Infof("Terragrunt Cache server is listening on %s", ln.Addr()) return ln, nil } // Run starts the webserver and workers. func (server *Server) Run(ctx context.Context, ln net.Listener) error { server.logger.Infof("Start Terragrunt Cache server") errGroup, ctx := errgroup.WithContext(ctx) for _, service := range server.services { errGroup.Go(func() error { return service.Run(ctx) }) } errGroup.Go(func() error { <-ctx.Done() server.logger.Infof("Shutting down Terragrunt Cache server...") ctx, cancel := context.WithTimeout(ctx, server.shutdownTimeout) defer cancel() if err := server.Shutdown(ctx); err != nil { return errors.New(err) } return nil }) if err := server.Server.Serve(ln); err != nil && err != http.ErrServerClosed { return errors.Errorf("error starting terragrunt cache server: %w", err) } defer server.logger.Infof("Terragrunt Cache server stopped") return errGroup.Wait() } ================================================ FILE: internal/tf/cache/services/provider_cache.go ================================================ // Package services provides services // that can be run in the background. package services import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "os" "path" "path/filepath" "slices" "sync" "time" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cache/helpers" "github.com/gruntwork-io/terragrunt/internal/tf/cache/models" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/log" svchost "github.com/hashicorp/terraform-svchost" "golang.org/x/sync/errgroup" ) const ( unzipFileMode = os.FileMode(0000) retryDelayLockFile = time.Second * 5 maxRetriesLockFile = 60 retryDelayFetchFile = time.Second * 2 maxRetriesFetchFile = 5 providerCacheWarmUpChBufferSize = 100 // DefaultProviderFileSizeLimit is the maximum total decompressed size for provider archives (1 GB) DefaultProviderFileSizeLimit = 1 << 30 // 1 GiB // DefaultProviderFilesLimit is the maximum number of files in a provider archive DefaultProviderFilesLimit = 100 ) type ProviderCaches []*ProviderCache func (caches ProviderCaches) Find(target *models.Provider) *ProviderCache { for _, cache := range caches { if cache.Match(target) { return cache } } return nil } func (caches ProviderCaches) FindByRequestID(requestID string) ProviderCaches { var foundCaches ProviderCaches for _, cache := range caches { if cache.containsRequestID(requestID) { foundCaches = append(foundCaches, cache) } } return foundCaches } func (caches ProviderCaches) removeArchive() error { for _, cache := range caches { if err := cache.removeArchive(); err != nil { return err } } return nil } type ProviderCache struct { err error *models.Provider *ProviderService started chan struct{} userProviderDir string packageDir string lockfilePath string archivePath string signature []byte documentSHA256Sums []byte requestIDs []string archiveCached bool ready bool mu sync.RWMutex } func (cache *ProviderCache) DocumentSHA256Sums(ctx context.Context) ([]byte, error) { if existing := cache.getDocumentSHA256Sums(); existing != nil || cache.SHA256SumsURL == "" { return existing, nil } return cache.setDocumentSHA256Sums(ctx) } func (cache *ProviderCache) Signature(ctx context.Context) ([]byte, error) { if existing := cache.getSignature(); existing != nil || cache.SHA256SumsSignatureURL == "" { return existing, nil } return cache.setSignature(ctx) } func (cache *ProviderCache) Version() string { return cache.Provider.Version } func (cache *ProviderCache) Address() string { return cache.Provider.Address() } func (cache *ProviderCache) Constraints() string { return cache.Provider.Constraints() } func (cache *ProviderCache) PackageDir() string { return cache.packageDir } func (cache *ProviderCache) AuthenticatePackage(ctx context.Context) (*getproviders.PackageAuthenticationResult, error) { var ( checksum [sha256.Size]byte documentSHA256Sums []byte signature []byte err error ) if documentSHA256Sums, err = cache.DocumentSHA256Sums(ctx); err != nil || documentSHA256Sums == nil { return nil, err } if signature, err = cache.Signature(ctx); err != nil || signature == nil { return nil, err } if _, err := hex.Decode(checksum[:], []byte(cache.SHA256Sum)); err != nil { return nil, errors.Errorf("registry response includes invalid SHA256 hash %q for provider %q: %w", cache.SHA256Sum, cache.Provider, err) } checks := []getproviders.PackageAuthentication{ getproviders.NewMatchingChecksumAuthentication(documentSHA256Sums, cache.Filename, checksum), getproviders.NewArchiveChecksumAuthentication(checksum), } if len(cache.SigningKeys.Keys()) != 0 { checks = append(checks, getproviders.NewSignatureAuthentication(documentSHA256Sums, signature, cache.SigningKeys.Keys())) } else { // `registry.opentofu.org` does not have signatures for some providers. cache.logger.Warnf("Signature validation was skipped due to the registry not containing GPG keys for the provider %s", cache.Provider) } return getproviders.PackageAuthenticationAll(checks...).Authenticate(cache.archivePath) } func (cache *ProviderCache) ArchivePath() string { exists, err := vfs.FileExists(cache.ProviderService.FS(), cache.archivePath) if err != nil { cache.logger.Warnf("Error checking archive path %s: %v", cache.archivePath, err) return "" } if exists { return cache.archivePath } return "" } func (cache *ProviderCache) addRequestID(requestID string) { cache.mu.Lock() defer cache.mu.Unlock() cache.requestIDs = append(cache.requestIDs, requestID) } func (cache *ProviderCache) containsRequestID(requestID string) bool { cache.mu.RLock() defer cache.mu.RUnlock() return slices.Contains(cache.requestIDs, requestID) } func (cache *ProviderCache) getRequestIDs() []string { cache.mu.RLock() defer cache.mu.RUnlock() result := make([]string, len(cache.requestIDs)) copy(result, cache.requestIDs) return result } func (cache *ProviderCache) isReady() bool { cache.mu.RLock() defer cache.mu.RUnlock() return cache.ready } func (cache *ProviderCache) setReady(ready bool) { cache.mu.Lock() defer cache.mu.Unlock() cache.ready = ready } func (cache *ProviderCache) getDocumentSHA256Sums() []byte { cache.mu.RLock() defer cache.mu.RUnlock() return cache.documentSHA256Sums } func (cache *ProviderCache) setDocumentSHA256Sums(ctx context.Context) ([]byte, error) { cache.mu.Lock() defer cache.mu.Unlock() if cache.documentSHA256Sums != nil { return cache.documentSHA256Sums, nil } var documentSHA256Sums = new(bytes.Buffer) req, err := cache.newRequest(ctx, cache.SHA256SumsURL) if err != nil { return nil, err } if err := helpers.Fetch(ctx, req, documentSHA256Sums); err != nil { return nil, fmt.Errorf("failed to retrieve authentication checksums for provider %q: %w", cache.Provider, err) } cache.documentSHA256Sums = documentSHA256Sums.Bytes() return cache.documentSHA256Sums, nil } func (cache *ProviderCache) getSignature() []byte { cache.mu.RLock() defer cache.mu.RUnlock() return cache.signature } func (cache *ProviderCache) setSignature(ctx context.Context) ([]byte, error) { cache.mu.Lock() defer cache.mu.Unlock() if cache.signature != nil { return cache.signature, nil } var signature = new(bytes.Buffer) req, err := cache.newRequest(ctx, cache.SHA256SumsSignatureURL) if err != nil { return nil, err } if err := helpers.Fetch(ctx, req, signature); err != nil { return nil, fmt.Errorf("failed to retrieve authentication signature for provider %q: %w", cache.Provider, err) } cache.signature = signature.Bytes() return cache.signature, nil } // warmUp checks if the required provider already exists in the cache directory, if not: // 1. Checks if the required provider exists in the user plugins directory, located at %APPDATA%\terraform.d\plugins on Windows and ~/.terraform.d/plugins on other systems. If so, creates a symlink to this folder. (Some providers are not available for darwin_arm64, in this case we can use https://github.com/kreuzwerker/m1-terraform-provider-helper which compiles and saves providers to the user plugins directory) // 2. Downloads the provider from the original registry, unpacks and saves it into the cache directory. func (cache *ProviderCache) warmUp(ctx context.Context) error { fs := cache.ProviderService.FS() exists, err := vfs.FileExists(fs, cache.packageDir) if err != nil { return errors.New(err) } if exists { return nil } if err := fs.MkdirAll(filepath.Dir(cache.packageDir), os.ModePerm); err != nil { return errors.New(err) } userProviderExists, err := vfs.FileExists(fs, cache.userProviderDir) if err != nil { return errors.New(err) } if userProviderExists { cache.logger.Debugf("Create symlink file %s to %s", cache.packageDir, cache.userProviderDir) if err := vfs.Symlink(fs, cache.userProviderDir, cache.packageDir); err != nil { return errors.New(err) } cache.logger.Infof("Cached %s from user plugins directory", cache.Provider) return nil } if cache.DownloadURL == "" { return errors.Errorf("not found provider download url") } downloadURLExists, err := vfs.FileExists(fs, cache.DownloadURL) if err != nil { return errors.New(err) } if downloadURLExists { cache.archivePath = cache.DownloadURL } else { if err := util.DoWithRetry(ctx, fmt.Sprintf("Fetching provider %s", cache.Provider), maxRetriesFetchFile, retryDelayFetchFile, cache.logger, log.DebugLevel, func(ctx context.Context) error { req, err := cache.newRequest(ctx, cache.DownloadURL) if err != nil { return err } return helpers.FetchToFile(ctx, req, cache.archivePath) }); err != nil { return err } cache.archiveCached = true } cache.logger.Debugf("Unpack provider archive %s", cache.archivePath) if err := vfs.NewZipDecompressor( vfs.WithFileSizeLimit(DefaultProviderFileSizeLimit), vfs.WithFilesLimit(DefaultProviderFilesLimit), ).Unzip( cache.logger, fs, cache.packageDir, cache.archivePath, unzipFileMode, ); err != nil { return err } auth, err := cache.AuthenticatePackage(ctx) if err != nil { return err } cache.logger.Infof("Cached %s (%s)", cache.Provider, auth) return nil } func (cache *ProviderCache) newRequest(ctx context.Context, url string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, errors.New(err) } if cache.credsSource == nil { return req, nil } hostname := svchost.Hostname(req.URL.Hostname()) if creds := cache.credsSource.ForHost(hostname); creds != nil { creds.PrepareRequest(req) } return req, nil } func (cache *ProviderCache) removeArchive() error { fs := cache.ProviderService.FS() if cache.archiveCached { exists, err := vfs.FileExists(fs, cache.archivePath) if err != nil { return errors.New(err) } if exists { cache.logger.Debugf("Remove provider cached archive %s", cache.archivePath) if err := fs.Remove(cache.archivePath); err != nil { return errors.New(err) } } } return nil } func (cache *ProviderCache) acquireLockFile(ctx context.Context) (*util.Lockfile, error) { lockfile := util.NewLockfile(cache.lockfilePath) if err := cache.ProviderService.FS().MkdirAll(filepath.Dir(cache.lockfilePath), os.ModePerm); err != nil { return nil, errors.New(err) } if err := util.DoWithRetry(ctx, "Acquiring lock file "+cache.lockfilePath, maxRetriesLockFile, retryDelayLockFile, cache.logger, log.DebugLevel, func(ctx context.Context) error { return lockfile.TryLock() }); err != nil { return nil, errors.Errorf("unable to acquire lock file %s (already locked?) try to remove the file manually: %w", cache.lockfilePath, err) } return lockfile, nil } // ProviderServiceOption configures a ProviderService. type ProviderServiceOption func(*ProviderService) // WithFS sets the filesystem for file operations. // If not set, defaults to the real OS filesystem. func WithFS(fs vfs.FS) ProviderServiceOption { return func(ps *ProviderService) { ps.fs = fs } } type ProviderService struct { logger log.Logger providerCacheWarmUpCh chan *ProviderCache credsSource *cliconfig.CredentialsSource // fs is the filesystem for file operations. fs vfs.FS // The path to store unpacked providers. The file structure is the same as terraform plugin cache dir. cacheDir string // The path to a predictable temporary directory for provider archives and lock files. tempDir string // the user plugins directory, by default: %APPDATA%\terraform.d\plugins on Windows, ~/.terraform.d/plugins on other systems. userCacheDir string providerCaches ProviderCaches cacheMu sync.RWMutex cacheReadyMu sync.RWMutex } // FS returns the configured filesystem. func (service *ProviderService) FS() vfs.FS { return service.fs } func NewProviderService( cacheDir, userCacheDir string, credsSource *cliconfig.CredentialsSource, l log.Logger, opts ...ProviderServiceOption, ) *ProviderService { service := &ProviderService{ cacheDir: cacheDir, userCacheDir: userCacheDir, providerCacheWarmUpCh: make(chan *ProviderCache, providerCacheWarmUpChBufferSize), credsSource: credsSource, logger: l, fs: vfs.NewOSFS(), } for _, opt := range opts { opt(service) } l.Debugf("Provider service initialized with cache dir: %s, user cache dir: %s", cacheDir, userCacheDir) return service } func (service *ProviderService) Logger() log.Logger { return service.logger } // WaitForCacheReady returns cached providers that were requested by `terraform init` from the cache server, with an URL containing the given `requestID` value. // The function returns the value only when all cache requests have been processed. func (service *ProviderService) WaitForCacheReady(requestID string) ([]getproviders.Provider, error) { service.cacheReadyMu.Lock() defer service.cacheReadyMu.Unlock() var ( providers []getproviders.Provider errs = &errors.MultiError{} ) service.logger.Debugf("Waiting for cache ready with requestID: %s", requestID) caches := service.providerCaches.FindByRequestID(requestID) service.logger.Debugf("Found %d caches for requestID: %s", len(caches), requestID) // Add debug logging for all provider caches service.logger.Debugf("Total provider caches: %d", len(service.providerCaches)) for i, cache := range service.providerCaches { service.logger.Debugf("Cache %d: %s, requestIDs: %v, ready: %v, err: %v", i, cache.Provider, cache.getRequestIDs(), cache.isReady(), cache.err) } for _, provider := range caches { if provider.err != nil { errs = errs.Append(fmt.Errorf("unable to cache provider: %s, err: %w", provider, provider.err)) service.logger.Errorf("Provider cache error for %s: %v", provider, provider.err) } if provider.isReady() { providers = append(providers, provider) service.logger.Debugf("Provider %s is ready", provider) } else { service.logger.Debugf("Provider %s is not ready yet", provider) } } service.logger.Debugf("Returning %d ready providers for requestID: %s", len(providers), requestID) return providers, errs.ErrorOrNil() } // CacheProvider starts caching the given provider using non-blocking approach. func (service *ProviderService) CacheProvider(ctx context.Context, requestID string, provider *models.Provider) *ProviderCache { service.cacheMu.Lock() defer service.cacheMu.Unlock() service.logger.Debugf("CacheProvider called for %s with requestID: %s", provider, requestID) if cache := service.providerCaches.Find(provider); cache != nil { service.logger.Debugf("Found existing cache for provider %s", provider) cache.addRequestID(requestID) return cache } packageName := fmt.Sprintf("%s-%s-%s-%s-%s", provider.RegistryName, provider.Namespace, provider.Name, provider.Version, provider.Platform()) cache := &ProviderCache{ ProviderService: service, Provider: provider, started: make(chan struct{}, 1), userProviderDir: filepath.Join(service.userCacheDir, provider.Address(), provider.Version, provider.Platform()), packageDir: filepath.Join(service.cacheDir, provider.Address(), provider.Version, provider.Platform()), lockfilePath: filepath.Join(service.tempDir, packageName+".lock"), archivePath: filepath.Join(service.tempDir, packageName+path.Ext(provider.Filename)), } service.logger.Debugf("Sending provider %s to warm up channel", provider) select { case service.providerCacheWarmUpCh <- cache: service.logger.Debugf("Successfully sent provider %s to warm up channel", provider) // We need to wait for caching to start and only then release the client (Terraform) requestID. Otherwise, the client may call `WaitForCacheReady()` faster than `service.ReadyMuReady` will be lock. <-cache.started service.providerCaches = append(service.providerCaches, cache) service.logger.Debugf("Added provider %s to provider caches list", provider) case <-ctx.Done(): service.logger.Debugf("Context cancelled while trying to cache provider %s", provider) } cache.addRequestID(requestID) service.logger.Debugf("Added requestID %s to provider %s", requestID, provider) return cache } // GetProviderCache returns the requested provider archive cache, if it exists. func (service *ProviderService) GetProviderCache(provider *models.Provider) *ProviderCache { service.cacheMu.RLock() defer service.cacheMu.RUnlock() cache := service.providerCaches.Find(provider) if cache != nil && cache.isReady() { return cache } return nil } // Run is responsible to handle a new caching requestID and removing temporary files upon completion. func (service *ProviderService) Run(ctx context.Context) error { if service.cacheDir == "" { return errors.Errorf("provider cache directory not specified") } service.logger.Debugf("Starting provider cache service with cache dir: %q", service.cacheDir) if err := service.FS().MkdirAll(service.cacheDir, os.ModePerm); err != nil { return errors.New(err) } tempDir, err := util.GetTempDir() if err != nil { return err } service.tempDir = filepath.Join(tempDir, "providers") service.logger.Debugf("Provider cache service temp dir: %s", service.tempDir) errs := &errors.MultiError{} errGroup, ctx := errgroup.WithContext(ctx) service.logger.Debugf("Provider cache service is ready to process requests") for { select { case cache := <-service.providerCacheWarmUpCh: service.logger.Debugf("Received provider cache request for: %s", cache.Provider) errGroup.Go(func() error { if err := service.startProviderCaching(ctx, cache); err != nil { service.logger.Errorf("Failed to start provider caching for %s: %v", cache.Provider, err) errs = errs.Append(err) } else { service.logger.Debugf("Successfully started provider caching for %s", cache.Provider) } return nil }) case <-ctx.Done(): service.logger.Debugf("Provider cache service shutting down...") if err := errGroup.Wait(); err != nil { errs = errs.Append(err) } if err := service.providerCaches.removeArchive(); err != nil { errs = errs.Append(err) } service.logger.Debugf("Provider cache service shutdown complete") return errs.ErrorOrNil() } } } func (service *ProviderService) startProviderCaching(ctx context.Context, cache *ProviderCache) error { service.cacheReadyMu.RLock() defer service.cacheReadyMu.RUnlock() service.logger.Debugf("Starting provider caching for: %s", cache.Provider) cache.started <- struct{}{} // We need to use a locking mechanism between Terragrunt processes to prevent simultaneous write access to the same provider. lockfile, err := cache.acquireLockFile(ctx) if err != nil { service.logger.Errorf("Failed to acquire lock file for %s: %v", cache.Provider, err) return err } defer lockfile.Unlock() //nolint:errcheck service.logger.Debugf("Acquired lock file for %s, starting warm up", cache.Provider) if cache.err = cache.warmUp(ctx); cache.err != nil { service.logger.Errorf("Failed to warm up provider %s: %v", cache.Provider, cache.err) if err := service.FS().RemoveAll(cache.packageDir); err != nil { service.logger.Warnf("Failed to clean up package dir %q: %v", cache.packageDir, err) } if err := service.FS().Remove(cache.archivePath); err != nil { service.logger.Warnf("Failed to clean up archive %q: %v", cache.archivePath, err) } return cache.err } cache.setReady(true) service.logger.Debugf("Successfully cached provider: %s", cache.Provider) return nil } ================================================ FILE: internal/tf/cache/services/service.go ================================================ // Package services provides the interface for services // that can be run in the background. package services import ( "context" ) type Service interface { Run(ctx context.Context) error } ================================================ FILE: internal/tf/cliconfig/config.go ================================================ // Package cliconfig provides methods to create an OpenTofu/Terraform CLI configuration file. package cliconfig import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclwrite" svchost "github.com/hashicorp/terraform-svchost" ) // ConfigHost is the structure of the "host" nested block within the CLI configuration, which can be used to override the default service host discovery behavior for a particular hostname. type ConfigHost struct { Services map[string]string `hcl:"services,attr"` Name string `hcl:",label"` } // ConfigCredentials is the structure of the "credentials" nested block within the CLI configuration. type ConfigCredentials struct { Name string `hcl:",label"` Token string `hcl:"token"` } // ConfigCredentialsHelper is the structure of the "credentials_helper" nested block within the CLI configuration. type ConfigCredentialsHelper struct { Name string `hcl:",label"` Args []string `hcl:"args"` } // ConfigOption configures a Config. type ConfigOption func(*Config) *Config // WithFS sets the filesystem for file operations. // If not set, defaults to the real OS filesystem. func WithFS(fs vfs.FS) ConfigOption { return func(cfg *Config) *Config { cfg.fs = fs return cfg } } // NewConfig creates a new Config with default values. func NewConfig() *Config { return &Config{ fs: vfs.NewOSFS(), } } // Config provides methods to create a terraform [CLI config file](https://developer.hashicorp.com/terraform/cli/config/config-file). // The main purpose of which is to create a local config that will inherit the default user CLI config and adding new sections to force Terraform to send requests through the Terragrunt Cache server and use the provider cache directory. type Config struct { CredentialsHelpers *ConfigCredentialsHelper `hcl:"credentials_helper,block"` ProviderInstallation *ProviderInstallation `hcl:"provider_installation,block"` // fs is the filesystem for saving config. Unexported to skip HCL encoding. // Defaults to vfs.NewOsFs() if nil. fs vfs.FS PluginCacheDir string `hcl:"plugin_cache_dir"` Credentials []ConfigCredentials `hcl:"credentials,block"` Hosts []ConfigHost `hcl:"host,block"` DisableCheckpoint bool `hcl:"disable_checkpoint"` DisableCheckpointSignature bool `hcl:"disable_checkpoint_signature"` } // WithOptions applies options to the Config. func (cfg *Config) WithOptions(opts ...ConfigOption) *Config { for _, opt := range opts { cfg = opt(cfg) } return cfg } // FS returns the configured filesystem. func (cfg *Config) FS() vfs.FS { return cfg.fs } // WithDisableCheckpoint sets DisableCheckpoint to true and returns the Config for chaining. func (cfg *Config) WithDisableCheckpoint() *Config { cfg.DisableCheckpoint = true return cfg } // WithDisableCheckpointSignature sets DisableCheckpointSignature to true and returns the Config for chaining. func (cfg *Config) WithDisableCheckpointSignature() *Config { cfg.DisableCheckpointSignature = true return cfg } // WithPluginCacheDir sets PluginCacheDir and returns the Config for chaining. func (cfg *Config) WithPluginCacheDir(dir string) *Config { cfg.PluginCacheDir = dir return cfg } // WithCredentials sets Credentials and returns the Config for chaining. func (cfg *Config) WithCredentials(credentials []ConfigCredentials) *Config { cfg.Credentials = credentials return cfg } // WithCredentialsHelpers sets CredentialsHelpers and returns the Config for chaining. func (cfg *Config) WithCredentialsHelpers(helpers *ConfigCredentialsHelper) *Config { cfg.CredentialsHelpers = helpers return cfg } // WithHosts sets Hosts and returns the Config for chaining. func (cfg *Config) WithHosts(hosts []ConfigHost) *Config { cfg.Hosts = hosts return cfg } // WithProviderInstallation sets ProviderInstallation and returns the Config for chaining. func (cfg *Config) WithProviderInstallation(installation *ProviderInstallation) *Config { cfg.ProviderInstallation = installation return cfg } func (cfg *Config) Clone() *Config { var providerInstallation *ProviderInstallation hosts := make([]ConfigHost, 0, len(cfg.Hosts)) hosts = append(hosts, cfg.Hosts...) if cfg.ProviderInstallation != nil { providerInstallation = &ProviderInstallation{ Methods: cfg.ProviderInstallation.Methods.Clone(), } } return &Config{ PluginCacheDir: cfg.PluginCacheDir, DisableCheckpoint: cfg.DisableCheckpoint, DisableCheckpointSignature: cfg.DisableCheckpointSignature, Credentials: cfg.Credentials, CredentialsHelpers: cfg.CredentialsHelpers, Hosts: hosts, ProviderInstallation: providerInstallation, fs: cfg.fs, } } // AddHost adds a host (officially undocumented), https://github.com/hashicorp/terraform/issues/28309 // It gives us opportunity rewrite path to the remote registry and the most important thing is that it works smoothly with HTTP (without HTTPS) // // host "registry.terraform.io" { // services = { // "providers.v1" = "http://localhost:5758/v1/providers/registry.terraform.io/", // } // } func (cfg *Config) AddHost(name string, services map[string]string) { cfg.Hosts = append(cfg.Hosts, ConfigHost{ Name: name, Services: services, }) } // AddProviderInstallationMethods merges new installation methods with the current ones, https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation // // provider_installation { // filesystem_mirror { // path = "/path/to/the/provider/cache" // include = ["example.com/*/*"] // } // direct { // exclude = ["example.com/*/*"] // } // } func (cfg *Config) AddProviderInstallationMethods(newMethods ...ProviderInstallationMethod) { if cfg.ProviderInstallation == nil { cfg.ProviderInstallation = &ProviderInstallation{} } cfg.ProviderInstallation.Methods = cfg.ProviderInstallation.Methods.Merge(newMethods...) } // Save marshalls and saves CLI config to the given path. func (cfg *Config) Save(configPath string) error { file := hclwrite.NewEmptyFile() gohcl.EncodeIntoBody(cfg, file.Body()) const ownerWriteGlobalReadPerms = 0644 if err := vfs.WriteFile(cfg.FS(), configPath, file.Bytes(), ownerWriteGlobalReadPerms); err != nil { return errors.New(err) } return nil } // CredentialsSource creates and returns a service credentials source whose behavior depends on which "credentials" if are present in the receiving config. func (cfg *Config) CredentialsSource() *CredentialsSource { configured := make(map[svchost.Hostname]string) for _, creds := range cfg.Credentials { host, err := svchost.ForComparison(creds.Name) if err != nil { // We expect the config was already validated by the time we get here, so we'll just ignore invalid hostnames. continue } configured[host] = creds.Token } return &CredentialsSource{ configured: configured, } } ================================================ FILE: internal/tf/cliconfig/config_test.go ================================================ package cliconfig_test import ( "fmt" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfig(t *testing.T) { t.Parallel() var ( include = []string{"registry.terraform.io/*/*"} exclude = []string{"registry.opentofu.org/*/*"} ) // Use a fixed path for the cache dir since we're using an in-memory filesystem tempCacheDir := "/tmp/provider-cache" testCases := []struct { config *cliconfig.Config expectedHCL string providerInstallationMethods []cliconfig.ProviderInstallationMethod hosts []cliconfig.ConfigHost }{ { providerInstallationMethods: []cliconfig.ProviderInstallationMethod{ cliconfig.NewProviderInstallationFilesystemMirror(tempCacheDir, include, exclude), cliconfig.NewProviderInstallationNetworkMirror("https://network-mirror.io/providers/", include, exclude), cliconfig.NewProviderInstallationDirect(include, exclude), }, hosts: []cliconfig.ConfigHost{ {Name: "registry.terraform.io", Services: map[string]string{"providers.v1": "http://localhost:5758/v1/providers/registry.terraform.io/"}}, }, config: cliconfig.NewConfig(). WithDisableCheckpoint(). WithPluginCacheDir("path/to/plugin/cache/dir1"), expectedHCL: ` provider_installation { "filesystem_mirror" { include = ["registry.terraform.io/*/*"] exclude = ["registry.opentofu.org/*/*"] path = "/tmp/provider-cache" } "network_mirror" { include = ["registry.terraform.io/*/*"] exclude = ["registry.opentofu.org/*/*"] url = "https://network-mirror.io/providers/" } "direct" { include = ["registry.terraform.io/*/*"] exclude = ["registry.opentofu.org/*/*"] } } plugin_cache_dir = "path/to/plugin/cache/dir1" host "registry.terraform.io" { services = { "providers.v1" = "http://localhost:5758/v1/providers/registry.terraform.io/" } } disable_checkpoint = true disable_checkpoint_signature = false `, }, { config: cliconfig.NewConfig(). WithPluginCacheDir(tempCacheDir), expectedHCL: ` provider_installation { } plugin_cache_dir = "/tmp/provider-cache" disable_checkpoint = false disable_checkpoint_signature = false `, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() // Use an in-memory filesystem for faster, isolated tests memFs := vfs.NewMemMapFS() configFile := "/config/.terraformrc" for _, host := range tc.hosts { tc.config.AddHost(host.Name, host.Services) } tc.config.AddProviderInstallationMethods(tc.providerInstallationMethods...) // Inject filesystem via options - same Save() method as production tc.config.WithOptions(cliconfig.WithFS(memFs)) err := tc.config.Save(configFile) require.NoError(t, err) hclBytes, err := vfs.ReadFile(memFs, configFile) require.NoError(t, err) actualHCL := string(hclBytes) assert.Equal(t, tc.expectedHCL, actualHCL) }) } } ================================================ FILE: internal/tf/cliconfig/credentials.go ================================================ package cliconfig import ( "os" "strings" svchost "github.com/hashicorp/terraform-svchost" svcauth "github.com/hashicorp/terraform-svchost/auth" ) type CredentialsSource struct { // configured describes the credentials explicitly configured in the CLI config via "credentials" blocks. configured map[svchost.Hostname]string } func (s *CredentialsSource) ForHost(host svchost.Hostname) svcauth.HostCredentials { // The first order of precedence for credentials is a host-specific environment variable if envCreds := hostCredentialsFromEnv(host); envCreds != nil { return envCreds } // Then, any credentials block present in the CLI config if token, ok := s.configured[host]; ok { return svcauth.HostCredentialsToken(token) } return nil } // hostCredentialsFromEnv returns a token credential by searching for a hostname-specific environment variable. The host parameter is expected to be in the "comparison" form, for example, hostnames containing non-ASCII characters like "café.fr" should be expressed as "xn--caf-dma.fr". If the variable based on the hostname is not defined, nil is returned. // // Hyphen and period characters are allowed in environment variable names, but are not valid POSIX variable names. However, it's still possible to set variable names with these characters using utilities like env or docker. Variable names may have periods translated to underscores and hyphens translated to double underscores in the variable name. For the example "café.fr", you may use the variable names "TF_TOKEN_xn____caf__dma_fr", "TF_TOKEN_xn--caf-dma_fr", or "TF_TOKEN_xn--caf-dma.fr" func hostCredentialsFromEnv(host svchost.Hostname) svcauth.HostCredentials { token, ok := collectCredentialsFromEnv()[host] if !ok { return nil } return svcauth.HostCredentialsToken(token) } func collectCredentialsFromEnv() map[svchost.Hostname]string { const prefix = "TF_TOKEN_" ret := make(map[svchost.Hostname]string) for _, ev := range os.Environ() { eqIdx := strings.Index(ev, "=") if eqIdx < 0 { continue } name := ev[:eqIdx] value := ev[eqIdx+1:] if !strings.HasPrefix(name, prefix) { continue } rawHost := name[len(prefix):] // We accept double underscores in place of hyphens because hyphens are not valid identifiers in most shells and are therefore hard to set. rawHost = strings.ReplaceAll(rawHost, "__", "-") // We accept underscores in place of dots because dots are not valid identifiers in most shells and are therefore hard to set. // Underscores are not valid in hostnames, so this is unambiguous for valid hostnames. rawHost = strings.ReplaceAll(rawHost, "_", ".") // Because environment variables are often set indirectly by OS libraries that might interfere with how they are encoded, we'll be tolerant of them being given either directly as UTF-8 IDNs or in Punycode form, normalizing to Punycode form here because that is what the OpenTofu credentials helper protocol will use in its requests. dispHost := svchost.ForDisplay(rawHost) hostname, err := svchost.ForComparison(dispHost) if err != nil { // Ignore invalid hostnames continue } ret[hostname] = value } return ret } ================================================ FILE: internal/tf/cliconfig/provider_installation.go ================================================ package cliconfig import ( "encoding/json" "fmt" "slices" "sort" "github.com/gruntwork-io/terragrunt/internal/util" ) // ProviderInstallation is the structure of the "provider_installation" nested block within the CLI configuration. type ProviderInstallation struct { Methods ProviderInstallationMethods `hcl:",block"` } type ProviderInstallationMethods []ProviderInstallationMethod func (methods ProviderInstallationMethods) Merge(withMethods ...ProviderInstallationMethod) ProviderInstallationMethods { mergedMethods := methods for _, withMethod := range withMethods { var isMerged bool for _, method := range methods { if method.Merge(withMethod) { isMerged = true break } } if !isMerged { mergedMethods = append(mergedMethods, withMethod) } } // place the `direct` method at the very end. sort.Slice(mergedMethods, func(i, j int) bool { if _, ok := mergedMethods[j].(*ProviderInstallationDirect); ok { return true } return false }) return mergedMethods } func (methods ProviderInstallationMethods) Clone() ProviderInstallationMethods { var cloned = make(ProviderInstallationMethods, len(methods)) for i, method := range methods { cloned[i] = method.Clone() } return cloned } // ProviderInstallationMethod is an interface type representing the different installation path types and represents an installation method block inside a provider_installation block. The concrete implementations of this interface are: // // ProviderInstallationDirect: install from the provider's origin registry // ProviderInstallationFilesystemMirror: install from a local filesystem mirror type ProviderInstallationMethod interface { fmt.Stringer AppendInclude(addrs []string) AppendExclude(addrs []string) RemoveInclude(addrs []string) RemoveExclude(addrs []string) Merge(with ProviderInstallationMethod) bool Clone() ProviderInstallationMethod } type ProviderInstallationDirect struct { Include *[]string `hcl:"include,optional" json:"Include"` Exclude *[]string `hcl:"exclude,optional" json:"Exclude"` Name string `hcl:",label" json:"Name"` } func NewProviderInstallationDirect(include, exclude []string) *ProviderInstallationDirect { res := &ProviderInstallationDirect{ Name: "direct", } if len(include) > 0 { res.Include = &include } if len(exclude) > 0 { res.Exclude = &exclude } return res } func (method *ProviderInstallationDirect) Clone() ProviderInstallationMethod { cloned := &ProviderInstallationDirect{ Name: method.Name, } if method.Include != nil { include := *method.Include cloned.Include = &include } if method.Exclude != nil { exclude := *method.Exclude cloned.Exclude = &exclude } return cloned } func (method *ProviderInstallationDirect) Merge(with ProviderInstallationMethod) bool { if with, ok := with.(*ProviderInstallationDirect); ok { if with.Exclude != nil { method.AppendExclude(*with.Exclude) } if with.Include != nil { method.AppendInclude(*with.Include) } return true } return false } func (method *ProviderInstallationDirect) AppendInclude(addrs []string) { if len(addrs) == 0 { return } if method.Include == nil { method.Include = &[]string{} } *method.Include = util.RemoveDuplicates(append(*method.Include, addrs...)) } func (method *ProviderInstallationDirect) AppendExclude(addrs []string) { if len(addrs) == 0 { return } if method.Exclude == nil { method.Exclude = &[]string{} } *method.Exclude = util.RemoveDuplicates(append(*method.Exclude, addrs...)) } func (method *ProviderInstallationDirect) RemoveExclude(addrs []string) { if len(addrs) == 0 || method.Exclude == nil { return } *method.Exclude = slices.DeleteFunc(*method.Exclude, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Exclude) == 0 { method.Exclude = nil } } func (method *ProviderInstallationDirect) RemoveInclude(addrs []string) { if len(addrs) == 0 || method.Include == nil { return } *method.Include = slices.DeleteFunc(*method.Include, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Include) == 0 { method.Include = nil } } func (method *ProviderInstallationDirect) String() string { // Odd that this err isn't checked. There should be an explanation why. b, _ := json.Marshal(method) //nolint:errchkjson return string(b) } type ProviderInstallationFilesystemMirror struct { Include *[]string `hcl:"include,optional" json:"Include"` Exclude *[]string `hcl:"exclude,optional" json:"Exclude"` Name string `hcl:",label" json:"Name"` Path string `hcl:"path,attr" json:"Path"` } func NewProviderInstallationFilesystemMirror(path string, include, exclude []string) *ProviderInstallationFilesystemMirror { res := &ProviderInstallationFilesystemMirror{ Name: "filesystem_mirror", Path: path, } if len(include) > 0 { res.Include = &include } if len(exclude) > 0 { res.Exclude = &exclude } return res } func (method *ProviderInstallationFilesystemMirror) Clone() ProviderInstallationMethod { cloned := &ProviderInstallationFilesystemMirror{ Name: method.Name, Path: method.Path, } if method.Include != nil { include := *method.Include cloned.Include = &include } if method.Exclude != nil { exclude := *method.Exclude cloned.Exclude = &exclude } return cloned } func (method *ProviderInstallationFilesystemMirror) Merge(with ProviderInstallationMethod) bool { if with, ok := with.(*ProviderInstallationFilesystemMirror); ok && method.Path == with.Path { if with.Exclude != nil { method.AppendExclude(*with.Exclude) } if with.Include != nil { method.AppendInclude(*with.Include) } return true } return false } func (method *ProviderInstallationFilesystemMirror) AppendInclude(addrs []string) { if len(addrs) == 0 { return } if method.Include == nil { method.Include = &[]string{} } *method.Include = util.RemoveDuplicates(append(*method.Include, addrs...)) } func (method *ProviderInstallationFilesystemMirror) AppendExclude(addrs []string) { if len(addrs) == 0 { return } if method.Exclude == nil { method.Exclude = &[]string{} } *method.Exclude = util.RemoveDuplicates(append(*method.Exclude, addrs...)) } func (method *ProviderInstallationFilesystemMirror) RemoveExclude(addrs []string) { if len(addrs) == 0 || method.Exclude == nil { return } *method.Exclude = slices.DeleteFunc(*method.Exclude, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Exclude) == 0 { method.Exclude = nil } } func (method *ProviderInstallationFilesystemMirror) RemoveInclude(addrs []string) { if len(addrs) == 0 || method.Include == nil { return } *method.Include = slices.DeleteFunc(*method.Include, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Include) == 0 { method.Include = nil } } func (method *ProviderInstallationFilesystemMirror) String() string { // Odd that this err isn't checked. There should be an explanation why. b, _ := json.Marshal(method) //nolint:errchkjson return string(b) } type ProviderInstallationNetworkMirror struct { Include *[]string `hcl:"include,optional" json:"Include"` Exclude *[]string `hcl:"exclude,optional" json:"Exclude"` Name string `hcl:",label" json:"Name"` URL string `hcl:"url,attr" json:"URL"` } func NewProviderInstallationNetworkMirror(url string, include, exclude []string) *ProviderInstallationNetworkMirror { res := &ProviderInstallationNetworkMirror{ Name: "network_mirror", URL: url, } if len(include) > 0 { res.Include = &include } if len(exclude) > 0 { res.Exclude = &exclude } return res } func (method *ProviderInstallationNetworkMirror) Clone() ProviderInstallationMethod { cloned := &ProviderInstallationNetworkMirror{ Name: method.Name, URL: method.URL, } if method.Include != nil { include := *method.Include cloned.Include = &include } if method.Exclude != nil { exclude := *method.Exclude cloned.Exclude = &exclude } return cloned } func (method *ProviderInstallationNetworkMirror) Merge(with ProviderInstallationMethod) bool { if with, ok := with.(*ProviderInstallationNetworkMirror); ok && method.URL == with.URL { if with.Exclude != nil { method.AppendExclude(*with.Exclude) } if with.Include != nil { method.AppendInclude(*with.Include) } return true } return false } func (method *ProviderInstallationNetworkMirror) AppendInclude(addrs []string) { if len(addrs) == 0 { return } if method.Include == nil { method.Include = &[]string{} } *method.Include = util.RemoveDuplicates(append(*method.Include, addrs...)) } func (method *ProviderInstallationNetworkMirror) AppendExclude(addrs []string) { if len(addrs) == 0 { return } if method.Exclude == nil { method.Exclude = &[]string{} } *method.Exclude = util.RemoveDuplicatesKeepLast(append(*method.Exclude, addrs...)) } func (method *ProviderInstallationNetworkMirror) RemoveExclude(addrs []string) { if len(addrs) == 0 || method.Exclude == nil { return } *method.Exclude = slices.DeleteFunc(*method.Exclude, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Exclude) == 0 { method.Exclude = nil } } func (method *ProviderInstallationNetworkMirror) RemoveInclude(addrs []string) { if len(addrs) == 0 || method.Include == nil { return } *method.Include = slices.DeleteFunc(*method.Include, func(item string) bool { return slices.Contains(addrs, item) }) if len(*method.Include) == 0 { method.Include = nil } } func (method *ProviderInstallationNetworkMirror) String() string { // Odd that this err isn't checked. There should be an explanation why. b, _ := json.Marshal(method) //nolint:errchkjson return string(b) } ================================================ FILE: internal/tf/cliconfig/user_config.go ================================================ package cliconfig import ( "path/filepath" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/tfdiags" ) // LoadUserConfig loads the user configuration is read as raw data and stored at the top of the saved configuration file. // The location of the default config is different for each OS https://developer.hashicorp.com/terraform/cli/config/config-file#locations func LoadUserConfig(opts ...ConfigOption) (*Config, error) { return loadUserConfig(cliconfig.LoadConfig, opts...) } func loadUserConfig( loadConfigFn func() (*cliconfig.Config, tfdiags.Diagnostics), opts ...ConfigOption, ) (*Config, error) { cfg, diag := loadConfigFn() if diag.HasErrors() { return nil, diag.Err() } config := NewConfig(). WithPluginCacheDir(cfg.PluginCacheDir). WithCredentials(getUserCredentials(cfg)). WithCredentialsHelpers(getUserCredentialsHelpers(cfg)). WithProviderInstallation(&ProviderInstallation{Methods: getUserProviderInstallationMethods(cfg)}). WithHosts(getUserHosts(cfg)) if cfg.DisableCheckpoint { config.WithDisableCheckpoint() } if cfg.DisableCheckpointSignature { config.WithDisableCheckpointSignature() } return config.WithOptions(opts...), nil } func UserProviderDir() (string, error) { configDir, err := cliconfig.ConfigDir() if err != nil { return "", errors.New(err) } return filepath.Join(configDir, "plugins"), nil } func getUserCredentials(cfg *cliconfig.Config) []ConfigCredentials { var credentials = make([]ConfigCredentials, 0, len(cfg.Credentials)) for name, credential := range cfg.Credentials { var token string if val, ok := credential["token"]; ok { if val, ok := val.(string); ok { token = val } } credential := ConfigCredentials{ Name: name, Token: token, } credentials = append(credentials, credential) } return credentials } func getUserCredentialsHelpers(cfg *cliconfig.Config) *ConfigCredentialsHelper { var credentialsHelpers *ConfigCredentialsHelper for name, helper := range cfg.CredentialsHelpers { var args []string if helper != nil { args = helper.Args } credentialsHelpers = &ConfigCredentialsHelper{ Name: name, Args: args, } } return credentialsHelpers } func getUserHosts(cfg *cliconfig.Config) []ConfigHost { var hosts = make([]ConfigHost, 0, len(cfg.Hosts)) for name, host := range cfg.Hosts { services := make(map[string]string) if host != nil { for key, val := range host.Services { if val, ok := val.(string); ok { services[key] = val } } } host := ConfigHost{Name: name, Services: services} hosts = append(hosts, host) } return hosts } func getUserProviderInstallationMethods(cfg *cliconfig.Config) []ProviderInstallationMethod { var methods []ProviderInstallationMethod for _, providerInstallation := range cfg.ProviderInstallation { for _, method := range providerInstallation.Methods { switch location := method.Location.(type) { case cliconfig.ProviderInstallationFilesystemMirror: methods = append(methods, NewProviderInstallationFilesystemMirror(string(location), method.Include, method.Exclude)) case cliconfig.ProviderInstallationNetworkMirror: methods = append(methods, NewProviderInstallationNetworkMirror(string(location), method.Include, method.Exclude)) default: methods = append(methods, NewProviderInstallationDirect(method.Include, method.Exclude)) } } } return methods } ================================================ FILE: internal/tf/context.go ================================================ package tf import ( "context" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( TerraformCommandContextKey ctxKey = iota DetailedExitCodeContextKey ) type ctxKey byte // RunShellCommandFunc is a context value for `TerraformCommandContextKey` key, used to intercept shell commands. type RunShellCommandFunc func(ctx context.Context, l log.Logger, tfOpts *TFOptions, args clihelper.Args) (*util.CmdOutput, error) func ContextWithTerraformCommandHook(ctx context.Context, fn RunShellCommandFunc) context.Context { ctx = cache.ContextWithCache(ctx) return context.WithValue(ctx, TerraformCommandContextKey, fn) } // TerraformCommandHookFromContext returns `RunShellCommandFunc` from the context if it has been set, otherwise returns nil. func TerraformCommandHookFromContext(ctx context.Context) RunShellCommandFunc { if val := ctx.Value(TerraformCommandContextKey); val != nil { if val, ok := val.(RunShellCommandFunc); ok { return val } } return nil } // ContextWithDetailedExitCode returns a new context containing the given DetailedExitCodeMap. func ContextWithDetailedExitCode(ctx context.Context, detailedExitCode *DetailedExitCodeMap) context.Context { return context.WithValue(ctx, DetailedExitCodeContextKey, detailedExitCode) } // DetailedExitCodeFromContext returns DetailedExitCodeMap if the given context contains it. func DetailedExitCodeFromContext(ctx context.Context) *DetailedExitCodeMap { if val := ctx.Value(DetailedExitCodeContextKey); val != nil { if val, ok := val.(*DetailedExitCodeMap); ok { return val } } return nil } ================================================ FILE: internal/tf/detailed_exitcode.go ================================================ package tf import ( "sync" ) const ( DetailedExitCodeSuccess = 0 DetailedExitCodeError = 1 DetailedExitCodeChanges = 2 ) // DetailedExitCodeMap stores exit codes per unit path. https://opentofu.org/docs/cli/commands/plan/ type DetailedExitCodeMap struct { codes map[string]int mu sync.RWMutex } // NewDetailedExitCodeMap creates a new DetailedExitCodeMap. func NewDetailedExitCodeMap() *DetailedExitCodeMap { return &DetailedExitCodeMap{ codes: make(map[string]int), } } // Set stores the exit code for the given path. Always updates the map without conditional logic. func (m *DetailedExitCodeMap) Set(path string, code int) { m.mu.Lock() defer m.mu.Unlock() if m.codes == nil { m.codes = make(map[string]int) } m.codes[path] = code } // Get returns the exit code for the given path, or 0 if not found. func (m *DetailedExitCodeMap) Get(path string) int { m.mu.RLock() defer m.mu.RUnlock() if m.codes == nil { return 0 } return m.codes[path] } // GetFinalDetailedExitCode computes the final exit code following OpenTofu's exit code convention: // - 0 = Success // - 1 = Error // - 2 = Success with changes pending // Aggregation rules for run --all: // - If any exit code is 1 (or > 2), return the max exit code // - If all exit codes are 0 or 2, return 2 // - If all exit codes are 0, return 0 func (m *DetailedExitCodeMap) GetFinalDetailedExitCode() int { m.mu.RLock() defer m.mu.RUnlock() if len(m.codes) == 0 { return 0 } hasError := false hasChanges := false maxCode := 0 for _, code := range m.codes { if code == DetailedExitCodeError || code > DetailedExitCodeChanges { hasError = true maxCode = max(maxCode, code) continue } if code == DetailedExitCodeChanges { hasChanges = true } } if hasError { return maxCode } if hasChanges { return DetailedExitCodeChanges } return DetailedExitCodeSuccess } // GetFinalExitCode computes the final exit code assuming the user hasn't supplied the -detailed-exitcode flag. // // In this case, we only care about any non-zero exit codes, so we'll return the highest exit code we can find. func (m *DetailedExitCodeMap) GetFinalExitCode() int { m.mu.RLock() defer m.mu.RUnlock() maxCode := 0 for _, code := range m.codes { maxCode = max(maxCode, code) } return maxCode } ================================================ FILE: internal/tf/doc.go ================================================ // Package tf contains functions and routines for interacting with OpenTofu/Terraform. // // MAINTAINER'S NOTE: Ideally we would be able to reuse code from Terraform. However, terraform has moved to packaging // all its libraries under internal so that you can't use them as a library outside of Terraform. To respect the // direction and spirit of the Terraform team, we opted for not doing anything funky to workaround the limitation (like // copying those files in here). We also opted to keep this functionality internal to align with the Terraform team's // decision to not support client libraries for accessing the Terraform Registry. package tf ================================================ FILE: internal/tf/errors.go ================================================ package tf import "fmt" // MalformedRegistryURLErr is returned if the Terraform Registry URL passed to the Getter is malformed. type MalformedRegistryURLErr struct { reason string } func (err MalformedRegistryURLErr) Error() string { return "tfr getter URL is malformed: " + err.reason } // ServiceDiscoveryErr is returned if Terragrunt failed to identify the module API endpoint through the service // discovery protocol. type ServiceDiscoveryErr struct { reason string } func (err ServiceDiscoveryErr) Error() string { return "Error identifying module registry API location: " + err.reason } // ModuleDownloadErr is returned if Terragrunt failed to download the module. type ModuleDownloadErr struct { sourceURL string details string } func (err ModuleDownloadErr) Error() string { return fmt.Sprintf("Error downloading module from %s: %s", err.sourceURL, err.details) } // RegistryAPIErr is returned if we get an unsuccessful HTTP return code from the registry. type RegistryAPIErr struct { url string statusCode int } func (err RegistryAPIErr) Error() string { return fmt.Sprintf("Failed to fetch url %s: status code %d", err.url, err.statusCode) } ================================================ FILE: internal/tf/getproviders/constraints.go ================================================ package getproviders import ( "fmt" "maps" "os" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" ) // ProviderConstraints maps provider addresses to their version constraints from required_providers blocks type ProviderConstraints map[string]string // ParseProviderConstraints parses all .tf and .tofu files in the given directory and extracts required_providers constraints func ParseProviderConstraints(impl tfimpl.Type, workingDir string) (ProviderConstraints, error) { constraints := make(ProviderConstraints) var allFiles []string tfFiles, err := filepath.Glob(filepath.Join(workingDir, "*.tf")) if err != nil { return nil, errors.New(err) } allFiles = append(allFiles, tfFiles...) tofuFiles, err := filepath.Glob(filepath.Join(workingDir, "*.tofu")) if err != nil { return nil, errors.New(err) } allFiles = append(allFiles, tofuFiles...) // If no terraform files found, return empty constraints (not an error) if len(allFiles) == 0 { return constraints, nil } for _, file := range allFiles { fileConstraints, err := parseProviderConstraintsFromFile(impl, file) if err != nil { // Log parsing errors but continue processing other files // This allows partial success when some files have syntax errors continue } // Merge constraints from this file maps.Copy(constraints, fileConstraints) } return constraints, nil } // parseProviderConstraintsFromFile parses a single .tf file and extracts required_providers constraints func parseProviderConstraintsFromFile(impl tfimpl.Type, filename string) (ProviderConstraints, error) { constraints := make(ProviderConstraints) content, err := os.ReadFile(filename) if err != nil { return nil, errors.New(err) } // Parse the HCL file file, diags := hclsyntax.ParseConfig(content, filename, hcl.Pos{Line: 1, Column: 1}) if diags.HasErrors() { return nil, errors.New(diags) } // Walk through the file looking for terraform blocks with required_providers body, ok := file.Body.(*hclsyntax.Body) if !ok { return nil, errors.New("failed to parse HCL body") } for _, block := range body.Blocks { if block.Type != "terraform" { continue } // Look for required_providers block within terraform block for _, nestedBlock := range block.Body.Blocks { if nestedBlock.Type != "required_providers" { continue } // Parse each provider in the required_providers block providerConstraints := parseProvidersFromRequiredProvidersBlock(impl, nestedBlock) // Merge constraints from this required_providers block maps.Copy(constraints, providerConstraints) } } return constraints, nil } // parseProvidersFromRequiredProvidersBlock extracts provider constraints from a required_providers block func parseProvidersFromRequiredProvidersBlock(impl tfimpl.Type, block *hclsyntax.Block) ProviderConstraints { constraints := make(ProviderConstraints) // Parse the attributes in the required_providers block for name, attr := range block.Body.Attributes { // Skip if not an object expression (should be provider configuration) objExpr, ok := attr.Expr.(*hclsyntax.ObjectConsExpr) if !ok { continue } var source, version string // Extract source and version from the provider configuration for _, item := range objExpr.Items { keyExpr, ok := item.KeyExpr.(*hclsyntax.ObjectConsKeyExpr) if !ok { continue } // Get the key name keyName := "" if keyExpr.Wrapped != nil { // Try different types of key expressions switch expr := keyExpr.Wrapped.(type) { case *hclsyntax.TemplateExpr: if len(expr.Parts) == 1 { if literal, ok := expr.Parts[0].(*hclsyntax.LiteralValueExpr); ok { keyName = literal.Val.AsString() } } case *hclsyntax.ScopeTraversalExpr: // This handles bare identifiers like "source" or "version" if len(expr.Traversal) == 1 { if root, ok := expr.Traversal[0].(hcl.TraverseRoot); ok { keyName = root.Name } } case *hclsyntax.LiteralValueExpr: // Direct literal value if expr.Val.Type() == cty.String { keyName = expr.Val.AsString() } } } // Get the value var value string if templateExpr, ok := item.ValueExpr.(*hclsyntax.TemplateExpr); ok { if len(templateExpr.Parts) == 1 { if literal, ok := templateExpr.Parts[0].(*hclsyntax.LiteralValueExpr); ok { if literal.Val.Type() == cty.String { value = literal.Val.AsString() } } } } // Store source and version attributes switch keyName { case "source": source = value case "version": version = value } } // If we have both source and version, create the constraint mapping if source != "" && version != "" { // Normalize the source address to full registry format providerAddr := normalizeProviderAddress(impl, source) constraints[providerAddr] = normalizeVersionConstraint(version) } else if source == "" && version != "" { // If only version is specified, assume it's a hashicorp provider registryDomain := tf.GetDefaultRegistryDomain(impl) providerAddr := fmt.Sprintf("%s/hashicorp/%s", registryDomain, name) constraints[providerAddr] = normalizeVersionConstraint(version) } } return constraints } // normalizeProviderAddress converts provider source to full registry format func normalizeProviderAddress(impl tfimpl.Type, source string) string { parts := strings.Split(source, "/") registryDomain := tf.GetDefaultRegistryDomain(impl) const ( singlePart = 1 twoPartPath = 2 threePartPath = 3 ) switch len(parts) { case singlePart: // "aws" -> "registry.terraform.io/hashicorp/aws" or "registry.opentofu.org/hashicorp/aws" return fmt.Sprintf("%s/hashicorp/%s", registryDomain, parts[0]) case twoPartPath: // "hashicorp/aws" -> "registry.terraform.io/hashicorp/aws" or "registry.opentofu.org/hashicorp/aws" return fmt.Sprintf("%s/%s", registryDomain, source) case threePartPath: // "registry.terraform.io/hashicorp/aws" -> keep as is return source default: // Fallback to original if format is unexpected return source } } // normalizeVersionConstraint normalizes version constraints to the format expected by OpenTofu/Terraform lockfiles. // // This includes: // 1. Removing the "=" prefix if present // 2. Normalizing version numbers to full 3-part format (e.g., "2.2" becomes "2.2.0") // 3. Handling multi-part constraints (e.g., ">= 3.0, < 7.0" becomes ">= 3.0.0, < 7.0.0") func normalizeVersionConstraint(constraint string) string { constraint = strings.TrimSpace(constraint) parts := strings.Split(constraint, ",") normalized := make([]string, 0, len(parts)) for _, part := range parts { normalized = append(normalized, normalizeSingleConstraint(strings.TrimSpace(part))) } return strings.Join(normalized, ", ") } // normalizeSingleConstraint normalizes a single version constraint (no commas). func normalizeSingleConstraint(constraint string) string { if after, ok := strings.CutPrefix(constraint, "="); ok { constraint = strings.TrimSpace(after) } fields := strings.Fields(constraint) const justVersionParts = 1 if len(fields) == justVersionParts { if v, err := version.NewVersion(fields[0]); err == nil { return v.String() } return constraint } const operatorAndVersionParts = 2 if len(fields) == operatorAndVersionParts { operator := fields[0] versionStr := fields[1] if v, err := version.NewVersion(versionStr); err == nil { return fmt.Sprintf("%s %s", operator, v.String()) } } return constraint } ================================================ FILE: internal/tf/getproviders/constraints_test.go ================================================ package getproviders_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseProviderConstraints(t *testing.T) { t.Parallel() // Create a temporary directory for testing testDir := helpers.TmpDirWOSymlinks(t) // Create a test terraform file with required_providers block terraformContent := ` terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } cloudflare = { source = "cloudflare/cloudflare" version = "~> 4.0" } } } ` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644) require.NoError(t, err) // Test parsing with Terraform implementation constraints, err := getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) assert.Equal(t, "~> 5.0.0", constraints["registry.terraform.io/hashicorp/aws"]) assert.Equal(t, "~> 4.0.0", constraints["registry.terraform.io/cloudflare/cloudflare"]) // Test parsing with OpenTofu implementation constraints, err = getproviders.ParseProviderConstraints(tfimpl.OpenTofu, testDir) require.NoError(t, err) assert.Equal(t, "~> 5.0.0", constraints["registry.opentofu.org/hashicorp/aws"]) assert.Equal(t, "~> 4.0.0", constraints["registry.opentofu.org/cloudflare/cloudflare"]) } func TestParseProviderConstraintsWithImplicitProvider(t *testing.T) { t.Parallel() // Create a temporary directory for testing testDir := helpers.TmpDirWOSymlinks(t) // Create a test terraform file with implicit provider (no source specified) terraformContent := ` terraform { required_providers { aws = { version = "~> 5.0" } } } ` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644) require.NoError(t, err) // Test parsing with Terraform implementation constraints, err := getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) // Verify the parsed constraints default to terraform registry and are normalized assert.Equal(t, "~> 5.0.0", constraints["registry.terraform.io/hashicorp/aws"]) // Test parsing with OpenTofu implementation constraints, err = getproviders.ParseProviderConstraints(tfimpl.OpenTofu, testDir) require.NoError(t, err) // Verify the parsed constraints default to OpenTofu registry and are normalized assert.Equal(t, "~> 5.0.0", constraints["registry.opentofu.org/hashicorp/aws"]) } func TestParseProviderConstraintsWithEnvironmentOverride(t *testing.T) { // Create a temporary directory for testing testDir := helpers.TmpDirWOSymlinks(t) // Create a test terraform file with implicit provider (no source specified) terraformContent := ` terraform { required_providers { aws = { version = "~> 5.0" } custom = { source = "example/custom" version = "~> 1.0" } } } ` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644) require.NoError(t, err) // Set the environment variable to override the default registry customRegistry := "custom.registry.example.com" t.Setenv("TG_TF_DEFAULT_REGISTRY_HOST", customRegistry) // Test parsing with Terraform implementation - should use custom registry constraints, err := getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) // Verify the parsed constraints use custom registry for implicit providers and are normalized assert.Equal(t, "~> 5.0.0", constraints[customRegistry+"/hashicorp/aws"]) // Explicit source should use custom registry too and be normalized assert.Equal(t, "~> 1.0.0", constraints[customRegistry+"/example/custom"]) // Test parsing with OpenTofu implementation - should also use custom registry (environment override takes precedence) constraints, err = getproviders.ParseProviderConstraints(tfimpl.OpenTofu, testDir) require.NoError(t, err) // Verify the parsed constraints use custom registry even with OpenTofu and are normalized assert.Equal(t, "~> 5.0.0", constraints[customRegistry+"/hashicorp/aws"]) assert.Equal(t, "~> 1.0.0", constraints[customRegistry+"/example/custom"]) } func TestParseProviderConstraintsWithTofuFiles(t *testing.T) { t.Parallel() // Create a temporary directory for testing testDir := helpers.TmpDirWOSymlinks(t) // Create a .tf file with one provider tfContent := ` terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } ` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(tfContent), 0644) require.NoError(t, err) // Create a .tofu file with another provider tofuContent := ` terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 3.0" } } } ` err = os.WriteFile(filepath.Join(testDir, "providers.tofu"), []byte(tofuContent), 0644) require.NoError(t, err) // Test parsing with OpenTofu implementation constraints, err := getproviders.ParseProviderConstraints(tfimpl.OpenTofu, testDir) require.NoError(t, err) // Verify constraints from both .tf and .tofu files are parsed and normalized assert.Equal(t, "~> 5.0.0", constraints["registry.opentofu.org/hashicorp/aws"]) assert.Equal(t, "~> 3.0.0", constraints["registry.opentofu.org/hashicorp/azurerm"]) // Test parsing with Terraform implementation constraints, err = getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) // Verify constraints from both .tf and .tofu files are parsed with Terraform registry and normalized assert.Equal(t, "~> 5.0.0", constraints["registry.terraform.io/hashicorp/aws"]) assert.Equal(t, "~> 3.0.0", constraints["registry.terraform.io/hashicorp/azurerm"]) } func TestParseProviderConstraintsWithEqualsPrefix(t *testing.T) { t.Parallel() // Create a temporary directory for testing testDir := helpers.TmpDirWOSymlinks(t) // Create a test terraform file with "=" prefix in version constraints terraformContent := ` terraform { required_providers { aws = { source = "hashicorp/aws" version = "= 5.100.0" } cloudflare = { source = "cloudflare/cloudflare" version = "= 4.40.0" } time = { source = "hashicorp/time" version = ">= 0.10.0" } } } ` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644) require.NoError(t, err) // Test parsing with Terraform implementation constraints, err := getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) // Verify the parsed constraints are normalized (no "=" prefix) assert.Equal(t, "5.100.0", constraints["registry.terraform.io/hashicorp/aws"]) assert.Equal(t, "4.40.0", constraints["registry.terraform.io/cloudflare/cloudflare"]) assert.Equal(t, ">= 0.10.0", constraints["registry.terraform.io/hashicorp/time"]) // Test parsing with OpenTofu implementation constraints, err = getproviders.ParseProviderConstraints(tfimpl.OpenTofu, testDir) require.NoError(t, err) // Verify the parsed constraints are normalized with OpenTofu registry assert.Equal(t, "5.100.0", constraints["registry.opentofu.org/hashicorp/aws"]) assert.Equal(t, "4.40.0", constraints["registry.opentofu.org/cloudflare/cloudflare"]) assert.Equal(t, ">= 0.10.0", constraints["registry.opentofu.org/hashicorp/time"]) } func TestNormalizeVersionConstraint(t *testing.T) { t.Parallel() testCases := []struct { name string input string expected string }{ { name: "normalize basic version constraint", input: ">= 2.2", expected: ">= 2.2.0", }, { name: "normalize pessimistic constraint", input: "~> 4.0", expected: "~> 4.0.0", }, { name: "already normalized constraint unchanged", input: ">= 2.2.0", expected: ">= 2.2.0", }, { name: "remove equals prefix", input: "= 1.0", expected: "1.0.0", }, { name: "complex constraint with patch version", input: "~> 3.14.15", expected: "~> 3.14.15", }, { name: "exact version constraint", input: "1.2", expected: "1.2.0", }, { name: "invalid constraint returned as-is", input: "invalid-constraint", expected: "invalid-constraint", }, { name: "whitespace handling", input: " >= 1.0 ", expected: ">= 1.0.0", }, { name: "equals prefix with whitespace", input: "= 2.5", expected: "2.5.0", }, { name: "multi-part constraint normalizes each part", input: ">= 3.0, < 7.0", expected: ">= 3.0.0, < 7.0.0", }, { name: "multi-part constraint with three parts", input: ">= 2.0, >= 3.0, < 7.0", expected: ">= 2.0.0, >= 3.0.0, < 7.0.0", }, { name: "multi-part already normalized", input: ">= 3.0.0, < 7.0.0", expected: ">= 3.0.0, < 7.0.0", }, { name: "multi-part with mixed operators", input: "~> 5.0, != 5.3", expected: "~> 5.0.0, != 5.3.0", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() // We need to call the unexported function through the public API // So we'll test it through the constraint parsing testDir := helpers.TmpDirWOSymlinks(t) terraformContent := `terraform { required_providers { test = { source = "example/test" version = "` + tc.input + `" } } }` err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644) require.NoError(t, err) constraints, err := getproviders.ParseProviderConstraints(tfimpl.Terraform, testDir) require.NoError(t, err) result := constraints["registry.terraform.io/example/test"] assert.Equal(t, tc.expected, result, "Input: %s", tc.input) }) } } ================================================ FILE: internal/tf/getproviders/hash.go ================================================ package getproviders import ( "bufio" "bytes" "crypto/sha256" "encoding/hex" "io" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/errors" "golang.org/x/mod/sumdb/dirhash" ) // Hash is a specially-formatted string representing a checksum of a package or the contents of the package. type Hash string func (hash Hash) String() string { return string(hash) } // HashScheme is an enumeration of schemes. type HashScheme string const ( // HashSchemeZip is the scheme identifier for the legacy hash scheme that applies to distribution archives (.zip files) rather than package contents. HashSchemeZip HashScheme = HashScheme("zh:") ) // New creates a new Hash value with the receiver as its scheme and the given raw string as its value. func (scheme HashScheme) New(value string) Hash { return Hash(string(scheme) + value) } // PackageHashLegacyZipSHA implements the old provider package hashing scheme of taking a SHA256 hash of the containing .zip archive itself, rather than of the contents of the archive. func PackageHashLegacyZipSHA(path string) (Hash, error) { archivePath, err := filepath.EvalSymlinks(path) if err != nil { return "", errors.New(err) } file, err := os.Open(archivePath) if err != nil { return "", errors.New(err) } defer file.Close() hash := sha256.New() if _, err = io.Copy(hash, file); err != nil { return "", errors.New(err) } gotHash := hash.Sum(nil) return HashSchemeZip.New(hex.EncodeToString(gotHash)), nil } // HashLegacyZipSHAFromSHA is a convenience method to produce the schemed-string hash format from an already-calculated hash of a provider .zip archive. func HashLegacyZipSHAFromSHA(sum [sha256.Size]byte) Hash { return HashSchemeZip.New(hex.EncodeToString(sum[:])) } // PackageHashV1 computes a hash of the contents of the package at the given location using hash algorithm 1. The resulting Hash is guaranteed to have the scheme HashScheme1. func PackageHashV1(path string) (Hash, error) { // We'll first dereference a possible symlink at our PackageDir location, as would be created if this package were linked in from another cache. packageDir, err := filepath.EvalSymlinks(path) if err != nil { return "", err } if fileInfo, err := os.Stat(packageDir); err != nil { return "", errors.New(err) } else if !fileInfo.IsDir() { return "", errors.Errorf("packageDir is not a directory %q", packageDir) } s, err := dirhash.HashDir(packageDir, "", dirhash.Hash1) return Hash(s), err } func DocumentHashes(doc []byte) []Hash { var hashes []Hash sc := bufio.NewScanner(bytes.NewReader(doc)) for sc.Scan() { parts := bytes.Fields(sc.Bytes()) columns := 2 if len(parts) != columns { // Doesn't look like a valid sums file line, so we'll assume this whole thing isn't a checksums file. continue } // If this is a checksums file then the first part should be a hex-encoded SHA256 hash, so it should be 64 characters long and contain only hex digits. hashStr := parts[0] hashLen := 64 if len(hashStr) != hashLen { return nil // doesn't look like a checksums file } var gotSHA256Sum [sha256.Size]byte if _, err := hex.Decode(gotSHA256Sum[:], hashStr); err != nil { return nil // doesn't look like a checksums file } hashes = append(hashes, HashLegacyZipSHAFromSHA(gotSHA256Sum)) } return hashes } ================================================ FILE: internal/tf/getproviders/hash_test.go ================================================ package getproviders_test import ( "fmt" "os" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func createFakeZipArchive(t *testing.T, content []byte) string { t.Helper() file, err := os.CreateTemp(helpers.TmpDirWOSymlinks(t), "*") require.NoError(t, err) defer file.Close() _, err = file.Write(content) require.NoError(t, err) return file.Name() } func TestPackageHashLegacyZipSHA(t *testing.T) { t.Parallel() testCases := []struct { path string expectedHash getproviders.Hash }{ { createFakeZipArchive(t, []byte("1234567890")), "zh:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646", }, { createFakeZipArchive(t, []byte("0987654321")), "zh:17756315ebd47b7110359fc7b168179bf6f2df3646fcc888bc8aa05c78b38ac1", }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() hash, err := getproviders.PackageHashLegacyZipSHA(tc.path) require.NoError(t, err) assert.Equal(t, tc.expectedHash, hash) }) } } ================================================ FILE: internal/tf/getproviders/lock.go ================================================ //go:generate mockgen -source=$GOFILE -destination=mocks/mock_$GOFILE -package=mocks package getproviders import ( "bytes" "context" "encoding/json" "os" "path/filepath" "slices" "sort" "strings" "unicode" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" ) // UpdateLockfile updates the dependency lock file. If `.terraform.lock.hcl` does not exist, it will be created, otherwise it will be updated. func UpdateLockfile(ctx context.Context, workingDir string, providers []Provider) error { var ( filename = filepath.Join(workingDir, tf.TerraformLockFile) file = hclwrite.NewFile() ) if util.FileExists(filename) { content, err := os.ReadFile(filename) if err != nil { return errors.New(err) } var diags hcl.Diagnostics file, diags = hclwrite.ParseConfig(content, filename, hcl.Pos{Line: 1, Column: 1}) if diags.HasErrors() { return errors.New(diags) } } if err := updateLockfile(ctx, file, providers); err != nil { return err } const ownerWriteGlobalReadPerms = 0644 if err := os.WriteFile(filename, file.Bytes(), ownerWriteGlobalReadPerms); err != nil { return errors.New(err) } return nil } func updateLockfile(ctx context.Context, file *hclwrite.File, providers []Provider) error { sort.Slice(providers, func(i, j int) bool { return providers[i].Address() < providers[j].Address() }) for _, provider := range providers { providerBlock := file.Body().FirstMatchingBlock("provider", []string{provider.Address()}) if providerBlock != nil { // update the existing provider block if err := updateProviderBlock(ctx, providerBlock, provider); err != nil { return err } } else { // create a new provider block file.Body().AppendNewline() providerBlock = file.Body().AppendNewBlock("provider", []string{provider.Address()}) if err := updateProviderBlock(ctx, providerBlock, provider); err != nil { return err } } } return nil } // updateProviderBlock updates the provider block in the dependency lock file. func updateProviderBlock(ctx context.Context, providerBlock *hclwrite.Block, provider Provider) error { hashes, err := getExistingHashes(providerBlock, provider) if err != nil { return err } providerBlock.Body().SetAttributeValue("version", cty.StringVal(provider.Version())) // If version constraints exist in current lock file and match the new version, we keep them unchanged. // Otherwise, we specify the constraints attribute the same as the version. currentConstraintsAttr := providerBlock.Body().GetAttribute("constraints") if shouldUpdateConstraints(currentConstraintsAttr, provider.Version()) { // Use module constraints if available, otherwise fall back to exact version constraintsValue := provider.Constraints() if constraintsValue == "" { constraintsValue = provider.Version() } providerBlock.Body().SetAttributeValue("constraints", cty.StringVal(constraintsValue)) } h1Hash, err := PackageHashV1(provider.PackageDir()) if err != nil { return err } newHashes := []Hash{h1Hash} documentSHA256Sums, err := provider.DocumentSHA256Sums(ctx) if err != nil { return err } if documentSHA256Sums != nil { zipHashes := DocumentHashes(documentSHA256Sums) newHashes = append(newHashes, zipHashes...) } // merge with existing hashes for _, newHashe := range newHashes { if !slices.Contains(hashes, newHashe) { hashes = append(hashes, newHashe) } } slices.Sort(hashes) providerBlock.Body().SetAttributeRaw("hashes", tokensForListPerLine(hashes)) return nil } func getExistingHashes(providerBlock *hclwrite.Block, provider Provider) ([]Hash, error) { versionAttr := providerBlock.Body().GetAttribute("version") if versionAttr == nil { return nil, nil } var hashes []Hash // a version attribute found versionVal := getAttributeValueAsUnquotedString(versionAttr) if versionVal == provider.Version() { // if version is equal, get already existing hashes from lock file to merge. if attr := providerBlock.Body().GetAttribute("hashes"); attr != nil { vals, err := getAttributeValueAsSlice(attr) if err != nil { return nil, err } for _, val := range vals { hashes = append(hashes, Hash(val)) } } } return hashes, nil } // shouldUpdateConstraints returns true if the constraints attribute should be updated // based on the current lock file state and the new provider version. func shouldUpdateConstraints(currentConstraintsAttr *hclwrite.Attribute, providerVersion string) bool { if currentConstraintsAttr == nil { return true } currentConstraintsValue := strings.ReplaceAll( string(currentConstraintsAttr.Expr().BuildTokens(nil).Bytes()), `"`, "", ) currentConstraints, err := version.NewConstraint(currentConstraintsValue) if err != nil { return true } newVersion, err := version.NewVersion(providerVersion) if err != nil { return true } return !currentConstraints.Check(newVersion) } // getAttributeValueAsString returns a value of Attribute as string. There is no way to get value as string directly, so we parses tokens of Attribute and build string representation. func getAttributeValueAsUnquotedString(attr *hclwrite.Attribute) string { // find TokenEqual expr := attr.Expr() exprTokens := expr.BuildTokens(nil) // TokenIdent records SpaceBefore, but we should ignore it here. quotedValue := strings.TrimSpace(string(exprTokens.Bytes())) // unquote value := strings.Trim(quotedValue, "\"") return value } // getAttributeValueAsSlice returns a value of Attribute as slice. func getAttributeValueAsSlice(attr *hclwrite.Attribute) ([]string, error) { expr := attr.Expr() exprTokens := expr.BuildTokens(nil) valBytes := bytes.TrimFunc(exprTokens.Bytes(), func(r rune) bool { if unicode.IsSpace(r) || r == ']' || r == ',' { return true } return false }) valBytes = append(valBytes, ']') var val []string if err := json.Unmarshal(valBytes, &val); err != nil { return nil, errors.New(err) } return val, nil } // tokensForListPerLine builds a hclwrite.Tokens for a given hashes, but breaks the line for each element. func tokensForListPerLine(hashes []Hash) hclwrite.Tokens { // The original TokensForValue implementation does not break line by line for hashes, so we build a token sequence by ourselves. tokens := append(hclwrite.Tokens{}, &hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte{'['}}, &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}}) for _, hash := range hashes { ts := hclwrite.TokensForValue(cty.StringVal(hash.String())) tokens = append(tokens, ts...) tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte{','}}, &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}}) } tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte{']'}}) return tokens } // UpdateLockfileConstraints updates only the constraints in an existing lock file // This is used for upgrade scenarios where module constraints have changed // but no providers were newly downloaded func UpdateLockfileConstraints(ctx context.Context, workingDir string, constraints ProviderConstraints) error { filename := filepath.Join(workingDir, tf.TerraformLockFile) if !util.FileExists(filename) { return nil } content, err := os.ReadFile(filename) if err != nil { return errors.New(err) } file, diags := hclwrite.ParseConfig(content, filename, hcl.Pos{Line: 1, Column: 1}) if diags.HasErrors() { return errors.New(diags) } updated := false // Update constraints for each provider in the lock file for providerAddr, newConstraint := range constraints { providerBlock := file.Body().FirstMatchingBlock("provider", []string{providerAddr}) if providerBlock == nil { continue } currentConstraintsAttr := providerBlock.Body().GetAttribute("constraints") if currentConstraintsAttr == nil { continue } currentConstraintsValue := strings.ReplaceAll(string(currentConstraintsAttr.Expr().BuildTokens(nil).Bytes()), `"`, "") currentConstraints, err := version.NewConstraint(currentConstraintsValue) if err != nil { providerBlock.Body().SetAttributeValue("constraints", cty.StringVal(newConstraint)) updated = true continue } versionAttr := providerBlock.Body().GetAttribute("version") if versionAttr != nil { versionVal := getAttributeValueAsUnquotedString(versionAttr) if v, err := version.NewVersion(versionVal); err == nil && currentConstraints.Check(v) { continue } } providerBlock.Body().SetAttributeValue("constraints", cty.StringVal(newConstraint)) updated = true } if updated { const ownerWriteGlobalReadPerms = 0644 if err := os.WriteFile(filename, file.Bytes(), ownerWriteGlobalReadPerms); err != nil { return errors.New(err) } } return nil } ================================================ FILE: internal/tf/getproviders/lock_test.go ================================================ //go:build mocks package getproviders_test import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders/mocks" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) func mockProviderWithConstraints(t *testing.T, ctrl *gomock.Controller, address, ver, constraints string) getproviders.Provider { t.Helper() packageDir := helpers.TmpDirWOSymlinks(t) file, err := os.Create(filepath.Join(packageDir, "terraform-provider-v"+ver)) require.NoError(t, err) _, err = fmt.Fprintf(file, "mock-provider-content-%s-%s", address, ver) require.NoError(t, err) err = file.Close() require.NoError(t, err) var document string var documentSB strings.Builder for i := 0; i < 2; i++ { packageName := fmt.Sprintf("%s-%s-%d", address, ver, i) hasher := sha256.New() _, err := hasher.Write([]byte(packageName)) require.NoError(t, err) sha := hex.EncodeToString(hasher.Sum(nil)) documentSB.WriteString(fmt.Sprintf("%s %s\n", sha, packageName)) } document += documentSB.String() provider := mocks.NewMockProvider(ctrl) provider.EXPECT().Address().Return(address).AnyTimes() provider.EXPECT().Version().Return(ver).AnyTimes() provider.EXPECT().Constraints().Return(constraints).AnyTimes() provider.EXPECT().PackageDir().Return(packageDir).AnyTimes() provider.EXPECT().Logger().Return(logger.CreateLogger()).AnyTimes() provider.EXPECT().DocumentSHA256Sums(gomock.Any()).Return([]byte(document), nil).AnyTimes() return provider } func mockProviderUpdateLock(t *testing.T, ctrl *gomock.Controller, address, version string) getproviders.Provider { t.Helper() packageDir := helpers.TmpDirWOSymlinks(t) file, err := os.Create(filepath.Join(packageDir, "terraform-provider-v"+version)) require.NoError(t, err) _, err = fmt.Fprintf(file, "mock-provider-content-%s-%s", address, version) require.NoError(t, err) err = file.Close() require.NoError(t, err) var document string var documentSB strings.Builder for i := 0; i < 2; i++ { packageName := fmt.Sprintf("%s-%s-%d", address, version, i) hasher := sha256.New() _, err := hasher.Write([]byte(packageName)) require.NoError(t, err) sha := hex.EncodeToString(hasher.Sum(nil)) documentSB.WriteString(fmt.Sprintf("%s %s\n", sha, packageName)) } document += documentSB.String() provider := mocks.NewMockProvider(ctrl) provider.EXPECT().Address().Return(address).AnyTimes() provider.EXPECT().Version().Return(version).AnyTimes() provider.EXPECT().Constraints().Return("").AnyTimes() provider.EXPECT().PackageDir().Return(packageDir).AnyTimes() provider.EXPECT().Logger().Return(logger.CreateLogger()).AnyTimes() provider.EXPECT().DocumentSHA256Sums(gomock.Any()).Return([]byte(document), nil).AnyTimes() return provider } func TestMockUpdateLockfile(t *testing.T) { t.Parallel() testCases := []struct { initialLockfile string expectedLockfile string providers []getproviders.Provider }{ { providers: []getproviders.Provider{}, initialLockfile: ``, expectedLockfile: ` provider "registry.terraform.io/hashicorp/aws" { version = "5.37.0" constraints = "5.37.0" hashes = [ "h1:SHOEBOHEif46z7Bb86YZ5evCtAeK5A4gtHdT8RU5OhA=", "zh:7c810fb11d8b3ded0cb554a27c27a9d002cc644a7a57c29cae01eeea890f0398", "zh:a3366f6b57b0f4b8bf8a5fecf42a834652709a97dd6db1b499c4ab186e33a41f", ] } `, }, { providers: []getproviders.Provider{}, initialLockfile: ` provider "registry.terraform.io/hashicorp/aws" { version = "5.37.0" constraints = "5.37.0" hashes = [ "h1:SHOEBOHEif46z7Bb86YZ5evCtAeK5A4gtHdT8RU5OhA=", "zh:7c810fb11d8b3ded0cb554a27c27a9d002cc644a7a57c29cae01eeea890f0398", "zh:a3366f6b57b0f4b8bf8a5fecf42a834652709a97dd6db1b499c4ab186e33a41f", ] } provider "registry.terraform.io/hashicorp/azurerm" { version = "3.101.0" constraints = "3.101.0" hashes = [ "h1:Jrkhx+qKaf63sIV/WvE8sPR53QuC16pvTrBjxFVMPYM=", "zh:38b02bce5cbe83f938a71716bbf9e8b07fed8b2c6b83c19b5e708eda7dee0f1d", "zh:3ed094366ab35c4fcd632471a7e45a84ca6c72b00477cdf1276e541a0171b369", ] } `, expectedLockfile: ` provider "registry.terraform.io/hashicorp/aws" { version = "5.36.0" constraints = "5.36.0" hashes = [ "h1:RpTjHdEAYqidB9hFPs68dIhkeIE1c/ZH9fEBdddf0Ik=", "zh:8721239b83a06212fb2f474d2acddfa2659a224ef66c77e28e1efe2290a30fa7", "zh:ed83a9620eab99e091b9f786f20f03fddb50cba030839fe0529bd518bfd67f8d", ] } provider "registry.terraform.io/hashicorp/azurerm" { version = "3.101.0" constraints = "3.101.0" hashes = [ "h1:Jrkhx+qKaf63sIV/WvE8sPR53QuC16pvTrBjxFVMPYM=", "zh:38b02bce5cbe83f938a71716bbf9e8b07fed8b2c6b83c19b5e708eda7dee0f1d", "zh:3ed094366ab35c4fcd632471a7e45a84ca6c72b00477cdf1276e541a0171b369", ] } provider "registry.terraform.io/hashicorp/template" { version = "2.2.0" constraints = "2.2.0" hashes = [ "h1:kvJsWhTmFya0WW8jAfY40fDtYhWQ6mOwPQC2ncDNjZs=", "zh:02d170f0a0f453155686baf35c10b5a7a230ef20ca49f6e26de1c2691ac70a59", "zh:d88ec10849d5a1d9d1db458847bbc62049f0282a2139e5176d645b75a0346992", ] } `, }, { providers: []getproviders.Provider{}, initialLockfile: ` provider "registry.terraform.io/hashicorp/aws" { version = "5.36.0" constraints = ">= 5.36.0" hashes = [ "h1:SHOEBOHEif46z7Bb86YZ5evCtAeK5A4gtHdT8RU5OhA=", "zh:7c810fb11d8b3ded0cb554a27c27a9d002cc644a7a57c29cae01eeea890f0398", "zh:a3366f6b57b0f4b8bf8a5fecf42a834652709a97dd6db1b499c4ab186e33a41f", ] } provider "registry.terraform.io/hashicorp/template" { version = "2.1.0" constraints = "<= 2.1.0" hashes = [ "h1:vxE/PD8SWl6Lmh5zRvIW1Y559xfUyuV2T/VeQLXi7f0=", "zh:6fc271665ac28c3fee773b9dc2b8066280ba35b7e9a14a6148194a240c43f42a", "zh:c19f719c9f7ce6d7449fe9c020100faed0705303c7f95beeef81dfd1e4a2004b", ] } `, expectedLockfile: ` provider "registry.terraform.io/hashicorp/aws" { version = "5.37.0" constraints = ">= 5.36.0" hashes = [ "h1:SHOEBOHEif46z7Bb86YZ5evCtAeK5A4gtHdT8RU5OhA=", "zh:7c810fb11d8b3ded0cb554a27c27a9d002cc644a7a57c29cae01eeea890f0398", "zh:a3366f6b57b0f4b8bf8a5fecf42a834652709a97dd6db1b499c4ab186e33a41f", ] } provider "registry.terraform.io/hashicorp/template" { version = "2.2.0" constraints = "2.2.0" hashes = [ "h1:kvJsWhTmFya0WW8jAfY40fDtYhWQ6mOwPQC2ncDNjZs=", "zh:02d170f0a0f453155686baf35c10b5a7a230ef20ca49f6e26de1c2691ac70a59", "zh:d88ec10849d5a1d9d1db458847bbc62049f0282a2139e5176d645b75a0346992", ] } `, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) defer ctrl.Finish() switch i { case 0: tc.providers = []getproviders.Provider{ mockProviderUpdateLock(t, ctrl, "registry.terraform.io/hashicorp/aws", "5.37.0"), } case 1: tc.providers = []getproviders.Provider{ mockProviderUpdateLock(t, ctrl, "registry.terraform.io/hashicorp/aws", "5.36.0"), mockProviderUpdateLock(t, ctrl, "registry.terraform.io/hashicorp/template", "2.2.0"), } case 2: tc.providers = []getproviders.Provider{ mockProviderUpdateLock(t, ctrl, "registry.terraform.io/hashicorp/aws", "5.37.0"), mockProviderUpdateLock(t, ctrl, "registry.terraform.io/hashicorp/template", "2.2.0"), } } workingDir := helpers.TmpDirWOSymlinks(t) lockfilePath := filepath.Join(workingDir, ".terraform.lock.hcl") if tc.initialLockfile != "" { file, err := os.Create(lockfilePath) require.NoError(t, err) _, err = file.WriteString(tc.initialLockfile) require.NoError(t, err) err = file.Close() require.NoError(t, err) } err := getproviders.UpdateLockfile(t.Context(), workingDir, tc.providers) require.NoError(t, err) actualLockfile, err := os.ReadFile(lockfilePath) require.NoError(t, err) assert.Equal(t, tc.expectedLockfile, string(actualLockfile)) }) } } // TestMockUpdateLockfilePreservesAggregatedConstraints verifies that when a lock file // already has valid aggregated constraints from the full dependency tree (e.g. // ">= 2.0.0, >= 3.0.0, >= 4.9.0, < 7.0.0"), and the provider's module-only // constraints differ (e.g. ">= 3.0.0, < 7.0.0"), the lock file constraints are // preserved as-is because the version still satisfies them. // This is the bug described in https://github.com/gruntwork-io/terragrunt/issues/5616 func TestMockUpdateLockfilePreservesAggregatedConstraints(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) defer ctrl.Finish() // Create a provider whose module-level constraints differ from the lock file's // aggregated constraints, but whose version satisfies the lock file constraints. provider := mockProviderWithConstraints(t, ctrl, "registry.terraform.io/hashicorp/aws", "5.37.0", ">= 3.0.0, < 7.0.0", // module-only constraints (subset of lock file) ) workingDir := helpers.TmpDirWOSymlinks(t) lockfilePath := filepath.Join(workingDir, ".terraform.lock.hcl") // Write a lock file with aggregated constraints from the full dependency tree. initialLockfile := ` provider "registry.terraform.io/hashicorp/aws" { version = "5.37.0" constraints = ">= 2.0.0, >= 3.0.0, >= 4.9.0, < 7.0.0" hashes = [ "h1:existing-hash=", ] } ` err := os.WriteFile(lockfilePath, []byte(initialLockfile), 0644) require.NoError(t, err) err = getproviders.UpdateLockfile(t.Context(), workingDir, []getproviders.Provider{provider}) require.NoError(t, err) actualLockfile, err := os.ReadFile(lockfilePath) require.NoError(t, err) // The constraints line must be preserved as the original aggregated constraints, // NOT overwritten with the module-only constraints. assert.Contains(t, string(actualLockfile), `constraints = ">= 2.0.0, >= 3.0.0, >= 4.9.0, < 7.0.0"`, "Lock file constraints should be preserved, not overwritten with module-only constraints") assert.NotContains(t, string(actualLockfile), `constraints = ">= 3.0.0, < 7.0.0"`, "Module-only constraints should not replace the aggregated constraints") } ================================================ FILE: internal/tf/getproviders/mocks/mock_lock.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: lock.go // // Generated by this command: // // mockgen -source=lock.go -destination=mocks/mock_lock.go -package=mocks // // Package mocks is a generated GoMock package. package mocks ================================================ FILE: internal/tf/getproviders/mocks/mock_provider.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: provider.go // // Generated by this command: // // mockgen -source=provider.go -destination=mocks/mock_provider.go -package=mocks // // Package mocks is a generated GoMock package. package mocks import ( context "context" reflect "reflect" log "github.com/gruntwork-io/terragrunt/pkg/log" gomock "go.uber.org/mock/gomock" ) // MockProvider is a mock of Provider interface. type MockProvider struct { ctrl *gomock.Controller recorder *MockProviderMockRecorder isgomock struct{} } // MockProviderMockRecorder is the mock recorder for MockProvider. type MockProviderMockRecorder struct { mock *MockProvider } // NewMockProvider creates a new mock instance. func NewMockProvider(ctrl *gomock.Controller) *MockProvider { mock := &MockProvider{ctrl: ctrl} mock.recorder = &MockProviderMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } // Address mocks base method. func (m *MockProvider) Address() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Address") ret0, _ := ret[0].(string) return ret0 } // Address indicates an expected call of Address. func (mr *MockProviderMockRecorder) Address() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Address", reflect.TypeOf((*MockProvider)(nil).Address)) } // Constraints mocks base method. func (m *MockProvider) Constraints() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Constraints") ret0, _ := ret[0].(string) return ret0 } // Constraints indicates an expected call of Constraints. func (mr *MockProviderMockRecorder) Constraints() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Constraints", reflect.TypeOf((*MockProvider)(nil).Constraints)) } // DocumentSHA256Sums mocks base method. func (m *MockProvider) DocumentSHA256Sums(ctx context.Context) ([]byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DocumentSHA256Sums", ctx) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } // DocumentSHA256Sums indicates an expected call of DocumentSHA256Sums. func (mr *MockProviderMockRecorder) DocumentSHA256Sums(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DocumentSHA256Sums", reflect.TypeOf((*MockProvider)(nil).DocumentSHA256Sums), ctx) } // Logger mocks base method. func (m *MockProvider) Logger() log.Logger { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Logger") ret0, _ := ret[0].(log.Logger) return ret0 } // Logger indicates an expected call of Logger. func (mr *MockProviderMockRecorder) Logger() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logger", reflect.TypeOf((*MockProvider)(nil).Logger)) } // PackageDir mocks base method. func (m *MockProvider) PackageDir() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PackageDir") ret0, _ := ret[0].(string) return ret0 } // PackageDir indicates an expected call of PackageDir. func (mr *MockProviderMockRecorder) PackageDir() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PackageDir", reflect.TypeOf((*MockProvider)(nil).PackageDir)) } // Version mocks base method. func (m *MockProvider) Version() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Version") ret0, _ := ret[0].(string) return ret0 } // Version indicates an expected call of Version. func (mr *MockProviderMockRecorder) Version() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Version", reflect.TypeOf((*MockProvider)(nil).Version)) } ================================================ FILE: internal/tf/getproviders/package_authentication.go ================================================ package getproviders import ( "bytes" "crypto/sha256" "encoding/hex" "io" "os" "strings" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/ProtonMail/go-crypto/openpgp" openpgpArmor "github.com/ProtonMail/go-crypto/openpgp/armor" openpgpErrors "github.com/ProtonMail/go-crypto/openpgp/errors" "github.com/ProtonMail/go-crypto/openpgp/packet" ) const ( VerifiedChecksum PackageAuthenticationResult = iota OfficialProvider PartnerProvider CommunityProvider ) // PackageAuthenticationResult is returned from a PackageAuthentication implementation which implements Stringer. type PackageAuthenticationResult int func NewPackageAuthenticationResult(res PackageAuthenticationResult) *PackageAuthenticationResult { return &res } func (result *PackageAuthenticationResult) String() string { if result == nil { return "unauthenticated" } return []string{ "verified checksum", "signed by HashiCorp", "signed by a HashiCorp partner", "self-signed", }[*result] } // SignedByHashiCorp returns whether the package was authenticated as signed by HashiCorp. func (result PackageAuthenticationResult) SignedByHashiCorp() bool { return result == OfficialProvider } // SignedByAnyParty returns whether the package was authenticated as signed by either HashiCorp or by a third-party. func (result PackageAuthenticationResult) SignedByAnyParty() bool { return result == OfficialProvider || result == PartnerProvider || result == CommunityProvider } // ThirdPartySigned returns whether the package was authenticated as signed by a party other than HashiCorp. func (result PackageAuthenticationResult) ThirdPartySigned() bool { return result == PartnerProvider || result == CommunityProvider } // PackageAuthentication implementation is responsible for authenticating that a package is what its distributor intended to distribute and that it has not been tampered with. type PackageAuthentication interface { // Authenticate takes the path of a package and returns a PackageAuthenticationResult, or an error if the authentication checks fail. Authenticate(path string) (*PackageAuthenticationResult, error) } // PackageAuthenticationHashes is an optional interface implemented by PackageAuthentication implementations that are able to return a set of hashes they would consider valid // if a given path referred to a package that matched that hash string. type PackageAuthenticationHashes interface { PackageAuthentication // AcceptableHashes returns a set of hashes that this authenticator considers to be valid for the current package or, where possible, equivalent packages on other platforms. AcceptableHashes() []Hash } type packageAuthenticationAll []PackageAuthentication // PackageAuthenticationAll combines several authentications together into a single check value, which passes only if all of the given ones pass. func PackageAuthenticationAll(checks ...PackageAuthentication) PackageAuthentication { return packageAuthenticationAll(checks) } func (checks packageAuthenticationAll) Authenticate(path string) (*PackageAuthenticationResult, error) { var authResult *PackageAuthenticationResult for _, check := range checks { var err error authResult, err = check.Authenticate(path) if err != nil { return authResult, err } } return authResult, nil } func (checks packageAuthenticationAll) AcceptableHashes() []Hash { for i := len(checks) - 1; i >= 0; i-- { check, ok := checks[i].(PackageAuthenticationHashes) if !ok { continue } allHashes := check.AcceptableHashes() if len(allHashes) > 0 { return allHashes } } return nil } type archiveHashAuthentication struct { WantSHA256Sum [sha256.Size]byte } // NewArchiveChecksumAuthentication returns a PackageAuthentication implementation that checks that the original distribution archive matches the given hash. func NewArchiveChecksumAuthentication(wantSHA256Sum [sha256.Size]byte) PackageAuthentication { return archiveHashAuthentication{wantSHA256Sum} } func (auth archiveHashAuthentication) Authenticate(path string) (*PackageAuthenticationResult, error) { if fileInfo, err := os.Stat(path); err != nil { return nil, errors.New(err) } else if fileInfo.IsDir() { return nil, errors.Errorf("cannot check archive hash for non-archive location %s", path) } gotHash, err := PackageHashLegacyZipSHA(path) if err != nil { return nil, errors.Errorf("failed to compute checksum for %s: %s", path, err) } wantHash := HashLegacyZipSHAFromSHA(auth.WantSHA256Sum) if gotHash != wantHash { return nil, errors.Errorf("archive has incorrect checksum %s (expected %s)", gotHash, wantHash) } return NewPackageAuthenticationResult(VerifiedChecksum), nil } func (auth archiveHashAuthentication) AcceptableHashes() []Hash { return []Hash{HashLegacyZipSHAFromSHA(auth.WantSHA256Sum)} } type matchingChecksumAuthentication struct { Filename string Document []byte WantSHA256Sum [sha256.Size]byte } // NewMatchingChecksumAuthentication returns a PackageAuthentication implementation that scans a registry-provided SHA256SUMS document for a specified filename, // and compares the SHA256 hash against the expected hash func NewMatchingChecksumAuthentication(document []byte, filename string, wantSHA256Sum [sha256.Size]byte) PackageAuthentication { return matchingChecksumAuthentication{ Document: document, Filename: filename, WantSHA256Sum: wantSHA256Sum, } } func (auth matchingChecksumAuthentication) Authenticate(location string) (*PackageAuthenticationResult, error) { // Find the checksum in the list with matching filename. The document is in the form "0123456789abcdef filename.zip". filename := []byte(auth.Filename) checksum := util.MatchSha256Checksum(auth.Document, filename) if checksum == nil { return nil, errors.Errorf("checksum list has no SHA-256 hash for %q", auth.Filename) } // Decode the ASCII checksum into a byte array for comparison. var gotSHA256Sum [sha256.Size]byte if _, err := hex.Decode(gotSHA256Sum[:], checksum); err != nil { return nil, errors.Errorf("checksum list has invalid SHA256 hash %q: %s", string(checksum), err) } // If the checksums don't match, authentication fails. if !bytes.Equal(gotSHA256Sum[:], auth.WantSHA256Sum[:]) { return nil, errors.Errorf("checksum list has unexpected SHA-256 hash %x (expected %x)", gotSHA256Sum, auth.WantSHA256Sum[:]) } return nil, nil } type signatureAuthentication struct { Keys map[string]string Document []byte Signature []byte } // NewSignatureAuthentication returns a PackageAuthentication implementation that verifies the cryptographic signature for a package against any of the provided keys. func NewSignatureAuthentication(document, signature []byte, keys map[string]string) PackageAuthentication { return signatureAuthentication{ Document: document, Signature: signature, Keys: keys, } } func (auth signatureAuthentication) Authenticate(location string) (*PackageAuthenticationResult, error) { // Find the key that signed the checksum file. This can fail if there is no valid signature for any of the provided keys. asciiArmor, trustSignature, err := auth.findSigningKey() if err != nil { return nil, err } // Verify the signature using the HashiCorp public key. If this succeeds, this is an official provider. hashicorpKeyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(HashicorpPublicKey)) if err != nil { return nil, errors.Errorf("error creating HashiCorp keyring: %s", err) } if err := auth.checkDetachedSignature(hashicorpKeyring, bytes.NewReader(auth.Document), bytes.NewReader(auth.Signature), nil); err == nil { return NewPackageAuthenticationResult(OfficialProvider), nil } // If the signing key has a trust signature, attempt to verify it with the HashiCorp partners public key. if trustSignature != "" { hashicorpPartnersKeyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(HashicorpPartnersKey)) if err != nil { return nil, errors.Errorf("error creating HashiCorp Partners keyring: %s", err) } authorKey, err := openpgpArmor.Decode(strings.NewReader(asciiArmor)) if err != nil { return nil, errors.Errorf("error decoding signing key: %s", err) } trustSignature, err := openpgpArmor.Decode(strings.NewReader(trustSignature)) if err != nil { return nil, errors.Errorf("error decoding trust signature: %s", err) } if err := auth.checkDetachedSignature(hashicorpPartnersKeyring, authorKey.Body, trustSignature.Body, nil); err != nil { return nil, errors.Errorf("error verifying trust signature: %s", err) } return NewPackageAuthenticationResult(PartnerProvider), nil } // We have a valid signature, but it's not from the HashiCorp key, and it also isn't a trusted partner. This is a community provider. return NewPackageAuthenticationResult(CommunityProvider), nil } func (auth signatureAuthentication) checkDetachedSignature(keyring openpgp.KeyRing, signed, signature io.Reader, config *packet.Config) error { entity, err := openpgp.CheckDetachedSignature(keyring, signed, signature, config) if errors.Is(err, openpgpErrors.ErrKeyExpired) { for id := range entity.Identities { log.Warnf("expired openpgp key from %s\n", id) } err = nil } return err } func (auth signatureAuthentication) AcceptableHashes() []Hash { return DocumentHashes(auth.Document) } // findSigningKey attempts to verify the signature using each of the keys returned by the registry. If a valid signature is found, it returns the signing key. func (auth signatureAuthentication) findSigningKey() (string, string, error) { for asciiArmor, trustSignature := range auth.Keys { keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(asciiArmor)) if err != nil { return "", "", errors.Errorf("error decoding signing key: %s", err) } if err := auth.checkDetachedSignature(keyring, bytes.NewReader(auth.Document), bytes.NewReader(auth.Signature), nil); err != nil { // If the signature issuer does not match the the key, keep trying the rest of the provided keys. if errors.Is(err, openpgpErrors.ErrUnknownIssuer) { continue } // Any other signature error is terminal. return "", "", errors.Errorf("error checking signature: %s", err) } return asciiArmor, trustSignature, nil } // If none of the provided keys issued the signature, this package is unsigned. This is currently a terminal authentication error. return "", "", errors.Errorf("authentication signature from unknown issuer") } ================================================ FILE: internal/tf/getproviders/package_authentication_test.go ================================================ package getproviders_test import ( "crypto/sha256" "encoding/base64" "errors" "fmt" "runtime" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/tf/getproviders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPackageAuthenticationResult(t *testing.T) { t.Parallel() testCases := []struct { result *getproviders.PackageAuthenticationResult expected string }{ { nil, "unauthenticated", }, { getproviders.NewPackageAuthenticationResult(getproviders.VerifiedChecksum), "verified checksum", }, { getproviders.NewPackageAuthenticationResult(getproviders.OfficialProvider), "signed by HashiCorp", }, { getproviders.NewPackageAuthenticationResult(getproviders.PartnerProvider), "signed by a HashiCorp partner", }, { getproviders.NewPackageAuthenticationResult(getproviders.CommunityProvider), "self-signed", }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, tc.result.String()) }) } } func TestArchiveChecksumAuthentication(t *testing.T) { t.Parallel() // Define platform-specific hashes for the test file linuxHash := [sha256.Size]byte{ 0x4f, 0xb3, 0x98, 0x49, 0xf2, 0xe1, 0x38, 0xeb, 0x16, 0xa1, 0x8b, 0xa0, 0xc6, 0x82, 0x63, 0x5d, 0x78, 0x1c, 0xb8, 0xc3, 0xb2, 0x59, 0x01, 0xdd, 0x5a, 0x79, 0x2a, 0xde, 0x97, 0x11, 0xf5, 0x01, } // Windows hash - this is the actual hash seen on Windows systems windowsHash := [sha256.Size]byte{ 0xa0, 0xd5, 0xc7, 0x0c, 0xb8, 0x17, 0xa8, 0xc2, 0x00, 0x52, 0x73, 0x1d, 0x4c, 0xa3, 0x48, 0xe1, 0xf2, 0xad, 0x95, 0x5d, 0xd3, 0xb1, 0x33, 0x72, 0x96, 0x34, 0xb2, 0x78, 0xaa, 0x61, 0x03, 0xde, } // Choose hash based on platform var expectedHash [sha256.Size]byte if runtime.GOOS == "windows" { expectedHash = windowsHash } else { expectedHash = linuxHash } testCases := []struct { expectedErr error expectedResult *getproviders.PackageAuthenticationResult path string wantSHA256Sum [sha256.Size]byte }{ { path: "testdata/filesystem-mirror/registry.terraform.io/hashicorp/null/terraform-provider-null_2.1.0_linux_amd64.zip", wantSHA256Sum: expectedHash, expectedResult: getproviders.NewPackageAuthenticationResult(getproviders.VerifiedChecksum), }, { path: "testdata/filesystem-mirror/registry.terraform.io/hashicorp/null/terraform-provider-null_invalid.zip", wantSHA256Sum: expectedHash, expectedErr: func() error { if runtime.GOOS == "windows" { return errors.New("archive has incorrect checksum zh:e8ad9768267f71ad74397f18c12fc073da9855d822817c5c4c2c25642e142e68 (expected zh:a0d5c70cb817a8c20052731d4ca348e1f2ad955dd3b133729634b278aa6103de)") } return errors.New("archive has incorrect checksum zh:8610a6d93c01e05a0d3920fe66c79b3c7c3b084f1f5c70715afd919fee1d978e (expected zh:4fb39849f2e138eb16a18ba0c682635d781cb8c3b25901dd5a792ade9711f501)") }(), }, { path: "testdata/no-package-here.zip", wantSHA256Sum: [sha256.Size]byte{}, expectedErr: errors.New("file not found: testdata/no-package-here.zip"), }, { path: "testdata/filesystem-mirror/registry.terraform.io/hashicorp/null/terraform-provider-null_2.1.0_linux_amd64.zip", wantSHA256Sum: [sha256.Size]byte{}, expectedErr: func() error { if runtime.GOOS == "windows" { return errors.New("archive has incorrect checksum zh:a0d5c70cb817a8c20052731d4ca348e1f2ad955dd3b133729634b278aa6103de (expected zh:0000000000000000000000000000000000000000000000000000000000000000)") } return errors.New("archive has incorrect checksum zh:4fb39849f2e138eb16a18ba0c682635d781cb8c3b25901dd5a792ade9711f501 (expected zh:0000000000000000000000000000000000000000000000000000000000000000)") }(), }, { path: "testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64", wantSHA256Sum: [sha256.Size]byte{}, expectedErr: errors.New("cannot check archive hash for non-archive location testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64"), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() auth := getproviders.NewArchiveChecksumAuthentication(tc.wantSHA256Sum) actualResult, actualErr := auth.Authenticate(tc.path) if tc.expectedErr != nil { if actualErr == nil { t.Fatalf("expected error %v but got no error", tc.expectedErr) } // For file not found errors, just check if it contains the expected text if strings.Contains(tc.expectedErr.Error(), "file not found") { if !strings.Contains(actualErr.Error(), "no such file") && !strings.Contains(actualErr.Error(), "cannot find the file") { t.Errorf("expected error containing 'file not found' but got: %v", actualErr) } } else { require.EqualError(t, actualErr, tc.expectedErr.Error()) } } else { require.NoError(t, actualErr) } assert.Equal(t, tc.expectedResult, actualResult) }) } } func TestNewMatchingChecksumAuthentication(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error path string filename string document []byte wantSHA256Sum [sha256.Size]byte }{ { path: "testdata/my-package.zip", filename: "my-package.zip", document: fmt.Appendf(nil, "%x README.txt\n%x my-package.zip\n", [sha256.Size]byte{0xc0, 0xff, 0xee}, [sha256.Size]byte{0xde, 0xca, 0xde}), wantSHA256Sum: [sha256.Size]byte{0xde, 0xca, 0xde}, }, { path: "testdata/my-package.zip", filename: "my-package.zip", document: fmt.Appendf(nil, "%x README.txt", [sha256.Size]byte{0xbe, 0xef}), wantSHA256Sum: [sha256.Size]byte{0xde, 0xca, 0xde}, expectedErr: errors.New(`checksum list has no SHA-256 hash for "my-package.zip"`), }, { path: "testdata/my-package.zip", filename: "my-package.zip", document: fmt.Appendf(nil, "%s README.txt\n%s my-package.zip", "horses", "chickens"), wantSHA256Sum: [sha256.Size]byte{0xde, 0xca, 0xde}, expectedErr: errors.New(`checksum list has invalid SHA256 hash "chickens": encoding/hex: invalid byte: U+0068 'h'`), }, { path: "testdata/my-package.zip", filename: "my-package.zip", document: fmt.Appendf(nil, "%x README.txt\n%x my-package.zip", [sha256.Size]byte{0xbe, 0xef}, [sha256.Size]byte{0xc0, 0xff, 0xee}), wantSHA256Sum: [sha256.Size]byte{0xde, 0xca, 0xde}, expectedErr: errors.New("checksum list has unexpected SHA-256 hash c0ffee0000000000000000000000000000000000000000000000000000000000 (expected decade0000000000000000000000000000000000000000000000000000000000)"), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() auth := getproviders.NewMatchingChecksumAuthentication(tc.document, tc.filename, tc.wantSHA256Sum) _, actualErr := auth.Authenticate(tc.path) if tc.expectedErr != nil { require.EqualError(t, actualErr, tc.expectedErr.Error()) } else { require.NoError(t, actualErr) } }) } } func TestSignatureAuthentication(t *testing.T) { t.Parallel() testCases := []struct { shasums string expectedHashes []getproviders.Hash }{ { `7d7e888fdd28abfe00894f9055209b9eec785153641de98e6852aa071008d4ee terraform_0.14.0-alpha20200923_darwin_amd64.zip f8b6cf9ade087c17826d49d89cef21261cdc22bd27065bbc5b27d7dbf7fbbf6c terraform_0.14.0-alpha20200923_freebsd_386.zip a5ba9945606bb7bfb821ba303957eeb40dd9ee4e706ba8da1eaf7cbeb0356e63 terraform_0.14.0-alpha20200923_freebsd_amd64.zip df3a5a8d6ffff7bacf19c92d10d0d500f98169ea17b3764b01a789f563d1aad7 terraform_0.14.0-alpha20200923_freebsd_arm.zip 086119a26576d06b8281a97e8644380da89ce16197cd955f74ea5ee664e9358b terraform_0.14.0-alpha20200923_linux_386.zip 1e5f7a5f3ade7b8b1d1d59c5cea2e1a2f8d2f8c3f41962dbbe8647e222be8239 terraform_0.14.0-alpha20200923_linux_amd64.zip 0e9fd0f3e2254b526a0e81e0cfdfc82583b0cd343778c53ead21aa7d52f776d7 terraform_0.14.0-alpha20200923_linux_arm.zip 66a947e7de1c74caf9f584c3ed4e91d2cb1af6fe5ce8abaf1cf8f7ff626a09d1 terraform_0.14.0-alpha20200923_openbsd_386.zip def1b73849bec0dc57a04405847921bf9206c75b52ae9de195476facb26bd85e terraform_0.14.0-alpha20200923_openbsd_amd64.zip 48f1826ec31d6f104e46cc2022b41f30cd1019ef48eaec9697654ef9ec37a879 terraform_0.14.0-alpha20200923_solaris_amd64.zip 17e0b496022bc4e4137be15e96d2b051c8acd6e14cb48d9b13b262330464f6cc terraform_0.14.0-alpha20200923_windows_386.zip 2696c86228f491bc5425561c45904c9ce39b1c676b1e17734cb2ee6b578c4bcd terraform_0.14.0-alpha20200923_windows_amd64.zip`, []getproviders.Hash{ "zh:7d7e888fdd28abfe00894f9055209b9eec785153641de98e6852aa071008d4ee", "zh:f8b6cf9ade087c17826d49d89cef21261cdc22bd27065bbc5b27d7dbf7fbbf6c", "zh:a5ba9945606bb7bfb821ba303957eeb40dd9ee4e706ba8da1eaf7cbeb0356e63", "zh:df3a5a8d6ffff7bacf19c92d10d0d500f98169ea17b3764b01a789f563d1aad7", "zh:086119a26576d06b8281a97e8644380da89ce16197cd955f74ea5ee664e9358b", "zh:1e5f7a5f3ade7b8b1d1d59c5cea2e1a2f8d2f8c3f41962dbbe8647e222be8239", "zh:0e9fd0f3e2254b526a0e81e0cfdfc82583b0cd343778c53ead21aa7d52f776d7", "zh:66a947e7de1c74caf9f584c3ed4e91d2cb1af6fe5ce8abaf1cf8f7ff626a09d1", "zh:def1b73849bec0dc57a04405847921bf9206c75b52ae9de195476facb26bd85e", "zh:48f1826ec31d6f104e46cc2022b41f30cd1019ef48eaec9697654ef9ec37a879", "zh:17e0b496022bc4e4137be15e96d2b051c8acd6e14cb48d9b13b262330464f6cc", "zh:2696c86228f491bc5425561c45904c9ce39b1c676b1e17734cb2ee6b578c4bcd", }, }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() auth := getproviders.NewSignatureAuthentication([]byte(tc.shasums), nil, nil) authWithHashes, ok := auth.(getproviders.PackageAuthenticationHashes) require.True(t, ok) actualHash := authWithHashes.AcceptableHashes() assert.Equal(t, tc.expectedHashes, actualHash) }) } } func TestSignatureAuthenticate(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error keys map[string]string expectedResult *getproviders.PackageAuthenticationResult path string signature string document []byte }{ { path: "testdata/my-package.zip", document: []byte(testProviderShaSums), signature: testHashicorpSignatureGoodBase64, keys: map[string]string{getproviders.HashicorpPublicKey: ""}, expectedResult: getproviders.NewPackageAuthenticationResult(getproviders.OfficialProvider), }, { path: "testdata/my-package.zip", document: []byte("example shasums data"), signature: testHashicorpSignatureGoodBase64, keys: map[string]string{"invalid PGP armor value": ""}, expectedErr: errors.New("error decoding signing key: openpgp: invalid argument: no armored data found"), }, { path: "testdata/my-package.zip", document: []byte("example shasums data"), signature: testSignatureBadBase64, keys: map[string]string{testAuthorKeyArmor: ""}, expectedErr: errors.New("error checking signature: openpgp: invalid data: signature subpacket truncated"), }, { path: "testdata/my-package.zip", document: []byte("example shasums data"), signature: testAuthorSignatureGoodBase64, keys: map[string]string{getproviders.HashicorpPublicKey: ""}, expectedErr: errors.New("authentication signature from unknown issuer"), }, { path: "testdata/my-package.zip", document: []byte("example shasums data"), signature: testAuthorSignatureGoodBase64, keys: map[string]string{testAuthorKeyArmor: "invalid PGP armor value"}, expectedErr: errors.New("error decoding trust signature: EOF"), }, { path: "testdata/my-package.zip", document: []byte("example shasums data"), signature: testAuthorSignatureGoodBase64, keys: map[string]string{testAuthorKeyArmor: testOtherKeyTrustSignatureArmor}, expectedErr: errors.New("error verifying trust signature: openpgp: invalid signature: RSA verification failure"), }, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() signature, err := base64.StdEncoding.DecodeString(tc.signature) require.NoError(t, err) auth := getproviders.NewSignatureAuthentication(tc.document, signature, tc.keys) actualResult, actualErr := auth.Authenticate(tc.path) if tc.expectedErr != nil { require.EqualError(t, actualErr, tc.expectedErr.Error()) } else { require.NoError(t, actualErr) } assert.Equal(t, tc.expectedResult, actualResult) }) } } // testHashicorpSignatureGoodBase64 is a signature of testProviderShaSums signed with // HashicorpPublicKey, which represents the SHA256SUMS.sig file downloaded for // an official release. const testHashicorpSignatureGoodBase64 = `wsFcBAABCAAQBQJgga+GCRCwtEEJdoW2dgAA` + `o0YQAAW911BGDr2WHLo5NwcZenwHyxL5DX9g+4BknKbc/WxRC1hD8Afi3eygZk1yR6eT4Gp2H` + `yNOwCjGL1PTONBumMfj9udIeuX8onrJMMvjFHh+bORGxBi4FKr4V3b2ZV1IYOjWMEyyTGRDvw` + `SCdxBkp3apH3s2xZLmRoAj84JZ4KaxGF7hlT0j4IkNyQKd2T5cCByN9DV80+x+HtzaOieFwJL` + `97iyGj6aznXfKfslK6S4oIrVTwyLTrQbxSxA0LsdUjRPHnJamL3sFOG77qUEUoXG3r61yi5vW` + `V4P5gCH/+C+VkfGHqaB1s0jHYLxoTEXtwthe66MydDBPe2Hd0J12u9ppOIeK3leeb4uiixWIi` + `rNdpWyjr/LU1KKWPxsDqMGYJ9TexyWkXjEpYmIEiY1Rxar8jrLh+FqVAhxRJajjgSRu5pZj50` + `CNeKmmbyolLhPCmICjYYU/xKPGXSyDFqonVVyMWCSpO+8F38OmwDQHIk5AWyc8hPOAZ+g5N95` + `cfUAzEqlvmNvVHQIU40Y6/Ip2HZzzFCLKQkMP1aDakYHq5w4ZO/ucjhKuoh1HDQMuMnZSu4eo` + `2nMTBzYZnUxwtROrJZF1t103avbmP2QE/GaPvLIQn7o5WMV3ZcPCJ+szzzby7H2e33WIynrY/` + `95ensBxh7mGFbcQ1C59b5o7viwIaaY2` // testSignatureBadBase64 is an invalid signature. const testSignatureBadBase64 = `iQEzBAABCAAdFiEEW/7sQxfnRgCGIZcGN6arO88s` + `4qIKqL6DwddBF4Ju2svn2MeNMGfE358H31mxAl2k4PPrwBTR1sFUCUOzAXVA/g9Ov5Y9ni2G` + `rkTahBtV9yuUUd1D+oRTTTdP0bj3A+3xxXmKTBhRuvurydPTicKuWzeILIJkcwp7Kl5UbI2N` + `n1ayZdaCIw/r4w==` // testAuthorKeyArmor is test key ID 5BFEEC4317E746008621970637A6AB3BCF2C170A. const testAuthorKeyArmor = `-----BEGIN PGP PUBLIC KEY BLOCK----- mQENBF5vhgYBCAC40OcC2hEx3yGiLhHMbt7DAVEQ0nZwAWy6oL98niknLumBa1VO nMYshP+o/FKOFatBl8aXhmDo606P6pD9d4Pg/WNehqT7hGNHcAFlm+8qjQAvE5uX Z/na/Np7dmWasCiL5hYyHEnKU/XFpc9KyicbkS7n8igP1LEb8xDD1pMLULQsQHA4 258asvtwjoYTZIij1I6bUE178bGFPNCfj+FzQM8nKzPpDVxZ7njN9c2sB9FEdJ1+ S9mZQNK5PbJuEAOpD5Jp9BnGE16jsLUhDmvGHBjFZAXMBkNSloEMHhs2ty9lEzoF eJmJx7XCGw+ds1SWp4MsHQPWzXxAlrfa4GMlABEBAAG0R1RlcnJhZm9ybSBUZXN0 aW5nIChwbHVnaW4vZGlzY292ZXJ5LykgPHRlcnJhZm9ybSt0ZXN0aW5nQGhhc2hp Y29ycC5jb20+iQFOBBMBCAA4FiEEW/7sQxfnRgCGIZcGN6arO88sFwoFAl5vhgYC GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQN6arO88sFwpWvQf/apaMu4Bm ea8AGjdl9acQhHBpWsyiHLIfZvN11xxN/f3+YITvPXIe2PMgveqNfXxu6PIeZGDb 0DBvnBQy/vqmA+sCQ8t8+kIWdfZ1EeM2YcXdmAEtriooLvc85JFYjafLIKSj9N7o V/R/e1BCW/v1/7Je47c+6FSt3HHhwyT5AZ3BCq1zpw6PeCDSQ/gZr3Mvq4CjeLA/ K+8TM3KyOF4qBGDvzGzp/t9umQSS2L0ozd90lxJtf5Q8ozqDaBiDo+f/osXT2EvN VwPP/xh/gABkXiNrPylFbeD+XPAC4N7NmYK5aPDzRYXXknP8e9PDMykoJKZ+bSdz F3IZ4q5RDHmmNbkBDQReb4YGAQgAt15e1F8TPQQm1jK8+scypHgfmPHbp7Qsulo1 GTcUd8QmhbR4kayuLDEpJYzq6+IoTM4TPqsdVuq/1Nwey9oyK0wXk/SUR29nRIQh 3GBg7JVg1YsObsfVTvEflYOdjk8T/Udqs4I6HnmSbtzsaohzybutpWXPUkW8OzFI ATwfVTrrz70Yxs+ly0nSEH2Yf+kg2uYZvv5KsJ3MNENhXnHnlaTy2IfhsxAX0xOG pa9fXV3NzdEbl0mYaEzMi77qRAyIQ9VrIL5F0yY/LlbpLSl6xk2+BB2v3a1Ey6SJ w4/le6AM0wlH2hKPCTlkvM0IvUWjlzrPzCkeu027iVc+fqdyiQARAQABiQE2BBgB CAAgFiEEW/7sQxfnRgCGIZcGN6arO88sFwoFAl5vhgYCGwwACgkQN6arO88sFwqz nAf/eF4oZG9F8sJX01mVdDm/L7Uthe4xjTdl7jwV4ygNX+pCyWrww3qc3qbd3QKg CFqIt/TAPE/OxHxCFuxalQefpOqfxjKzvcktxzWmpgxaWsvHaXiS4bKBPz78N/Ke MUtcjGHyLeSzYPUfjquqDzQxqXidRYhyHGSy9c0NKZ6wCElLZ6KcmCQb4sZxVwfu ssjwAFbPMp1nr0f5SWCJfhTh7QF7lO2ldJaKMlcBM8aebmqFQ52P7ZWOFcgeerng G7Zdrci1KEd943HhzDCsUFz4gJwbvUyiAYb2ddndpUBkYwCB/XrHWPOSnGxHgZoo 1gIqed9OV/+s5wKxZPjL0pCStQ== =mYqJ -----END PGP PUBLIC KEY BLOCK-----` // testAuthorSignatureGoodBase64 is a signature of testShaSums signed with // testAuthorKeyArmor, which represents the SHA256SUMS.sig file downloaded for // a release. const testAuthorSignatureGoodBase64 = `iQEzBAABCAAdFiEEW/7sQxfnRgCGIZcGN6arO88s` + `FwoFAl5vh7gACgkQN6arO88sFwrAlQf6Al77qzjxNIj+NQNJfBGYUE5jHIgcuWOs1IPRTYUI` + `rHQIUU2RVrdHoAefKTKNzGde653JK/pYTflSV+6ini3/aZZnXlF6t001w3wswmakdwTr0hXx` + `Ez/hHYio72Gpn7+T/L+nl6dKkjeGqd/Kor5x2TY9uYB737ESmAe5T8ZlPaGMFHh0mYlNTeRq` + `4qIKqL6DwddBF4Ju2svn2MeNMGfE358H31mxAl2k4PPrwBTR1sFUCUOzAXVA/g9Ov5Y9ni2G` + `rkTahBtV9yuUUd1D+oRTTTdP0bj3A+3xxXmKTBhRuvurydPTicKuWzeILIJkcwp7Kl5UbI2N` + `n1ayZdaCIw/r4w==` // testOtherKeyTrustSignatureArmor is a trust signature of another key (not the // author key), signed with HashicorpPartnersKey. const testOtherKeyTrustSignatureArmor = `-----BEGIN PGP SIGNATURE----- iQIzBAABCAAdFiEEUYkGV8Ws20uCMIZWfXLUJo5GYPwFAl6POvsACgkQfXLUJo5G YPyGihAAomM1kGmrC5KRgWQ+V47r8wFoIkhsTgAYb9ENOzn/RVJt3SJSstcKxfA3 7HW5R4kqAoXH1hcPYpUcOcdeAvtZxjGRQ9JgErV8NBg6sR11aQccCzAG4Hy0hWav /jB5NzTEX5JFEXH6WhpWI1avh0l2j6JxO1K1s+5+5PI3KbuO+XSqeZ3QmUz9FwGu pr0J6oYcERupzrpnmgMb5fbkpHfzffR2/MOYdF9Hae4EvDS1b7tokuuKsStNnCm0 ge7PFdekwbj/OiQrQlqM1pOw2siPX3ouWCtW8oExm9tAxNw31Bn2g3oaNMkHMqJd hlVUZlqeJMyylUat3cY7GTQONfCnoyUHe/wv8exBUbV3v2glp9y2g9i2XmXkHOrV Z+pnNBc+jdp3a4O0Y8fXXZdjiIolZKY8BbvzheuMrQQIOmw4N3KrZbTpLKuqz8rb h8bqUbU42oWcJmBvzF4NZ4tQ+aFHs4CbOnjfDfS14baQr2Gqo9BqTfrzS5Pbs8lq AhY0r+zi71lQ1rBfgZfjd8zWlOzpDO//nwKhGCqYOWke/C/T6o0zxM0R4uR4zXwT KhvXK8/kK/L8Flaxqme0d5bzXLbsMe9I6I76DY5iNhkiFnnWt4+FhGoIDR03MTKS SnHodBLlpKLyUXi36DCDy/iKVsieqLsAdcYe0nQFuhoQcOme33A= =aHOG -----END PGP SIGNATURE-----` const testProviderShaSums = `fea4227271ebf7d9e2b61b89ce2328c7262acd9fd190e1fd6d15a591abfa848e terraform-provider-null_3.1.0_darwin_amd64.zip 9ebf4d9704faba06b3ec7242c773c0fbfe12d62db7d00356d4f55385fc69bfb2 terraform-provider-null_3.1.0_darwin_arm64.zip a6576c81adc70326e4e1c999c04ad9ca37113a6e925aefab4765e5a5198efa7e terraform-provider-null_3.1.0_freebsd_386.zip 5f9200bf708913621d0f6514179d89700e9aa3097c77dac730e8ba6e5901d521 terraform-provider-null_3.1.0_freebsd_amd64.zip fc39cc1fe71234a0b0369d5c5c7f876c71b956d23d7d6f518289737a001ba69b terraform-provider-null_3.1.0_freebsd_arm.zip c797744d08a5307d50210e0454f91ca4d1c7621c68740441cf4579390452321d terraform-provider-null_3.1.0_linux_386.zip 53e30545ff8926a8e30ad30648991ca8b93b6fa496272cd23b26763c8ee84515 terraform-provider-null_3.1.0_linux_amd64.zip cecb6a304046df34c11229f20a80b24b1603960b794d68361a67c5efe58e62b8 terraform-provider-null_3.1.0_linux_arm64.zip e1371aa1e502000d9974cfaff5be4cfa02f47b17400005a16f14d2ef30dc2a70 terraform-provider-null_3.1.0_linux_arm.zip a8a42d13346347aff6c63a37cda9b2c6aa5cc384a55b2fe6d6adfa390e609c53 terraform-provider-null_3.1.0_windows_386.zip 02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2 terraform-provider-null_3.1.0_windows_amd64.zip ` ================================================ FILE: internal/tf/getproviders/provider.go ================================================ //go:generate mockgen -source=$GOFILE -destination=mocks/mock_$GOFILE -package=mocks // Package getproviders provides an interface for getting providers. package getproviders import ( "context" "github.com/gruntwork-io/terragrunt/pkg/log" ) type Provider interface { // Address returns a source address of the provider. e.g.: registry.terraform.io/hashicorp/aws Address() string // Version returns a version of the provider. e.g.: 5.36.0 Version() string // Constraints returns the version constraints from the module's required_providers block, or empty string if none. Constraints() string // DocumentSHA256Sums returns a document with providers hashes for different platforms. DocumentSHA256Sums(ctx context.Context) ([]byte, error) // PackageDir returns a directory with the unpacked provider. PackageDir() string // Logger returns logger Logger() log.Logger } ================================================ FILE: internal/tf/getproviders/public_keys.go ================================================ package getproviders // HashicorpPublicKey is the HashiCorp public key, also available at // https://www.hashicorp.com/security const HashicorpPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- mQINBGB9+xkBEACabYZOWKmgZsHTdRDiyPJxhbuUiKX65GUWkyRMJKi/1dviVxOX PG6hBPtF48IFnVgxKpIb7G6NjBousAV+CuLlv5yqFKpOZEGC6sBV+Gx8Vu1CICpl Zm+HpQPcIzwBpN+Ar4l/exCG/f/MZq/oxGgH+TyRF3XcYDjG8dbJCpHO5nQ5Cy9h QIp3/Bh09kET6lk+4QlofNgHKVT2epV8iK1cXlbQe2tZtfCUtxk+pxvU0UHXp+AB 0xc3/gIhjZp/dePmCOyQyGPJbp5bpO4UeAJ6frqhexmNlaw9Z897ltZmRLGq1p4a RnWL8FPkBz9SCSKXS8uNyV5oMNVn4G1obCkc106iWuKBTibffYQzq5TG8FYVJKrh RwWB6piacEB8hl20IIWSxIM3J9tT7CPSnk5RYYCTRHgA5OOrqZhC7JefudrP8n+M pxkDgNORDu7GCfAuisrf7dXYjLsxG4tu22DBJJC0c/IpRpXDnOuJN1Q5e/3VUKKW mypNumuQpP5lc1ZFG64TRzb1HR6oIdHfbrVQfdiQXpvdcFx+Fl57WuUraXRV6qfb 4ZmKHX1JEwM/7tu21QE4F1dz0jroLSricZxfaCTHHWNfvGJoZ30/MZUrpSC0IfB3 iQutxbZrwIlTBt+fGLtm3vDtwMFNWM+Rb1lrOxEQd2eijdxhvBOHtlIcswARAQAB tERIYXNoaUNvcnAgU2VjdXJpdHkgKGhhc2hpY29ycC5jb20vc2VjdXJpdHkpIDxz ZWN1cml0eUBoYXNoaWNvcnAuY29tPokCVAQTAQoAPhYhBMh0AR8KtAURDQIQVTQ2 XZRy10aPBQJgffsZAhsDBQkJZgGABQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ EDQ2XZRy10aPtpcP/0PhJKiHtC1zREpRTrjGizoyk4Sl2SXpBZYhkdrG++abo6zs buaAG7kgWWChVXBo5E20L7dbstFK7OjVs7vAg/OLgO9dPD8n2M19rpqSbbvKYWvp 0NSgvFTT7lbyDhtPj0/bzpkZEhmvQaDWGBsbDdb2dBHGitCXhGMpdP0BuuPWEix+ QnUMaPwU51q9GM2guL45Tgks9EKNnpDR6ZdCeWcqo1IDmklloidxT8aKL21UOb8t cD+Bg8iPaAr73bW7Jh8TdcV6s6DBFub+xPJEB/0bVPmq3ZHs5B4NItroZ3r+h3ke VDoSOSIZLl6JtVooOJ2la9ZuMqxchO3mrXLlXxVCo6cGcSuOmOdQSz4OhQE5zBxx LuzA5ASIjASSeNZaRnffLIHmht17BPslgNPtm6ufyOk02P5XXwa69UCjA3RYrA2P QNNC+OWZ8qQLnzGldqE4MnRNAxRxV6cFNzv14ooKf7+k686LdZrP/3fQu2p3k5rY 0xQUXKh1uwMUMtGR867ZBYaxYvwqDrg9XB7xi3N6aNyNQ+r7zI2lt65lzwG1v9hg FG2AHrDlBkQi/t3wiTS3JOo/GCT8BjN0nJh0lGaRFtQv2cXOQGVRW8+V/9IpqEJ1 qQreftdBFWxvH7VJq2mSOXUJyRsoUrjkUuIivaA9Ocdipk2CkP8bpuGz7ZF4uQIN BGB9+xkBEACoklYsfvWRCjOwS8TOKBTfl8myuP9V9uBNbyHufzNETbhYeT33Cj0M GCNd9GdoaknzBQLbQVSQogA+spqVvQPz1MND18GIdtmr0BXENiZE7SRvu76jNqLp KxYALoK2Pc3yK0JGD30HcIIgx+lOofrVPA2dfVPTj1wXvm0rbSGA4Wd4Ng3d2AoR G/wZDAQ7sdZi1A9hhfugTFZwfqR3XAYCk+PUeoFrkJ0O7wngaon+6x2GJVedVPOs 2x/XOR4l9ytFP3o+5ILhVnsK+ESVD9AQz2fhDEU6RhvzaqtHe+sQccR3oVLoGcat ma5rbfzH0Fhj0JtkbP7WreQf9udYgXxVJKXLQFQgel34egEGG+NlbGSPG+qHOZtY 4uWdlDSvmo+1P95P4VG/EBteqyBbDDGDGiMs6lAMg2cULrwOsbxWjsWka8y2IN3z 1stlIJFvW2kggU+bKnQ+sNQnclq3wzCJjeDBfucR3a5WRojDtGoJP6Fc3luUtS7V 5TAdOx4dhaMFU9+01OoH8ZdTRiHZ1K7RFeAIslSyd4iA/xkhOhHq89F4ECQf3Bt4 ZhGsXDTaA/VgHmf3AULbrC94O7HNqOvTWzwGiWHLfcxXQsr+ijIEQvh6rHKmJK8R 9NMHqc3L18eMO6bqrzEHW0Xoiu9W8Yj+WuB3IKdhclT3w0pO4Pj8gQARAQABiQI8 BBgBCgAmFiEEyHQBHwq0BRENAhBVNDZdlHLXRo8FAmB9+xkCGwwFCQlmAYAACgkQ NDZdlHLXRo9ZnA/7BmdpQLeTjEiXEJyW46efxlV1f6THn9U50GWcE9tebxCXgmQf u+Uju4hreltx6GDi/zbVVV3HCa0yaJ4JVvA4LBULJVe3ym6tXXSYaOfMdkiK6P1v JgfpBQ/b/mWB0yuWTUtWx18BQQwlNEQWcGe8n1lBbYsH9g7QkacRNb8tKUrUbWlQ QsU8wuFgly22m+Va1nO2N5C/eE/ZEHyN15jEQ+QwgQgPrK2wThcOMyNMQX/VNEr1 Y3bI2wHfZFjotmek3d7ZfP2VjyDudnmCPQ5xjezWpKbN1kvjO3as2yhcVKfnvQI5 P5Frj19NgMIGAp7X6pF5Csr4FX/Vw316+AFJd9Ibhfud79HAylvFydpcYbvZpScl 7zgtgaXMCVtthe3GsG4gO7IdxxEBZ/Fm4NLnmbzCIWOsPMx/FxH06a539xFq/1E2 1nYFjiKg8a5JFmYU/4mV9MQs4bP/3ip9byi10V+fEIfp5cEEmfNeVeW5E7J8PqG9 t4rLJ8FR4yJgQUa2gs2SNYsjWQuwS/MJvAv4fDKlkQjQmYRAOp1SszAnyaplvri4 ncmfDsf0r65/sd6S40g5lHH8LIbGxcOIN6kwthSTPWX89r42CbY8GzjTkaeejNKx v1aCrO58wAtursO1DiXCvBY7+NdafMRnoHwBk50iPqrVkNA8fv+auRyB2/G5Ag0E YH3+JQEQALivllTjMolxUW2OxrXb+a2Pt6vjCBsiJzrUj0Pa63U+lT9jldbCCfgP wDpcDuO1O05Q8k1MoYZ6HddjWnqKG7S3eqkV5c3ct3amAXp513QDKZUfIDylOmhU qvxjEgvGjdRjz6kECFGYr6Vnj/p6AwWv4/FBRFlrq7cnQgPynbIH4hrWvewp3Tqw GVgqm5RRofuAugi8iZQVlAiQZJo88yaztAQ/7VsXBiHTn61ugQ8bKdAsr8w/ZZU5 HScHLqRolcYg0cKN91c0EbJq9k1LUC//CakPB9mhi5+aUVUGusIM8ECShUEgSTCi KQiJUPZ2CFbbPE9L5o9xoPCxjXoX+r7L/WyoCPTeoS3YRUMEnWKvc42Yxz3meRb+ BmaqgbheNmzOah5nMwPupJYmHrjWPkX7oyyHxLSFw4dtoP2j6Z7GdRXKa2dUYdk2 x3JYKocrDoPHh3Q0TAZujtpdjFi1BS8pbxYFb3hHmGSdvz7T7KcqP7ChC7k2RAKO GiG7QQe4NX3sSMgweYpl4OwvQOn73t5CVWYp/gIBNZGsU3Pto8g27vHeWyH9mKr4 cSepDhw+/X8FGRNdxNfpLKm7Vc0Sm9Sof8TRFrBTqX+vIQupYHRi5QQCuYaV6OVr ITeegNK3So4m39d6ajCR9QxRbmjnx9UcnSYYDmIB6fpBuwT0ogNtABEBAAGJBHIE GAEKACYCGwIWIQTIdAEfCrQFEQ0CEFU0Nl2UctdGjwUCYH4bgAUJAeFQ2wJAwXQg BBkBCgAdFiEEs2y6kaLAcwxDX8KAsLRBCXaFtnYFAmB9/iUACgkQsLRBCXaFtnYX BhAAlxejyFXoQwyGo9U+2g9N6LUb/tNtH29RHYxy4A3/ZUY7d/FMkArmh4+dfjf0 p9MJz98Zkps20kaYP+2YzYmaizO6OA6RIddcEXQDRCPHmLts3097mJ/skx9qLAf6 rh9J7jWeSqWO6VW6Mlx8j9m7sm3Ae1OsjOx/m7lGZOhY4UYfY627+Jf7WQ5103Qs lgQ09es/vhTCx0g34SYEmMW15Tc3eCjQ21b1MeJD/V26npeakV8iCZ1kHZHawPq/ aCCuYEcCeQOOteTWvl7HXaHMhHIx7jjOd8XX9V+UxsGz2WCIxX/j7EEEc7CAxwAN nWp9jXeLfxYfjrUB7XQZsGCd4EHHzUyCf7iRJL7OJ3tz5Z+rOlNjSgci+ycHEccL YeFAEV+Fz+sj7q4cFAferkr7imY1XEI0Ji5P8p/uRYw/n8uUf7LrLw5TzHmZsTSC UaiL4llRzkDC6cVhYfqQWUXDd/r385OkE4oalNNE+n+txNRx92rpvXWZ5qFYfv7E 95fltvpXc0iOugPMzyof3lwo3Xi4WZKc1CC/jEviKTQhfn3WZukuF5lbz3V1PQfI xFsYe9WYQmp25XGgezjXzp89C/OIcYsVB1KJAKihgbYdHyUN4fRCmOszmOUwEAKR 3k5j4X8V5bk08sA69NVXPn2ofxyk3YYOMYWW8ouObnXoS8QJEDQ2XZRy10aPMpsQ AIbwX21erVqUDMPn1uONP6o4NBEq4MwG7d+fT85rc1U0RfeKBwjucAE/iStZDQoM ZKWvGhFR+uoyg1LrXNKuSPB82unh2bpvj4zEnJsJadiwtShTKDsikhrfFEK3aCK8 Zuhpiu3jxMFDhpFzlxsSwaCcGJqcdwGhWUx0ZAVD2X71UCFoOXPjF9fNnpy80YNp flPjj2RnOZbJyBIM0sWIVMd8F44qkTASf8K5Qb47WFN5tSpePq7OCm7s8u+lYZGK wR18K7VliundR+5a8XAOyUXOL5UsDaQCK4Lj4lRaeFXunXl3DJ4E+7BKzZhReJL6 EugV5eaGonA52TWtFdB8p+79wPUeI3KcdPmQ9Ll5Zi/jBemY4bzasmgKzNeMtwWP fk6WgrvBwptqohw71HDymGxFUnUP7XYYjic2sVKhv9AevMGycVgwWBiWroDCQ9Ja btKfxHhI2p+g+rcywmBobWJbZsujTNjhtme+kNn1mhJsD3bKPjKQfAxaTskBLb0V wgV21891TS1Dq9kdPLwoS4XNpYg2LLB4p9hmeG3fu9+OmqwY5oKXsHiWc43dei9Y yxZ1AAUOIaIdPkq+YG/PhlGE4YcQZ4RPpltAr0HfGgZhmXWigbGS+66pUj+Ojysc j0K5tCVxVu0fhhFpOlHv0LWaxCbnkgkQH9jfMEJkAWMOuQINBGCAXCYBEADW6RNr ZVGNXvHVBqSiOWaxl1XOiEoiHPt50Aijt25yXbG+0kHIFSoR+1g6Lh20JTCChgfQ kGGjzQvEuG1HTw07YhsvLc0pkjNMfu6gJqFox/ogc53mz69OxXauzUQ/TZ27GDVp UBu+EhDKt1s3OtA6Bjz/csop/Um7gT0+ivHyvJ/jGdnPEZv8tNuSE/Uo+hn/Q9hg 8SbveZzo3C+U4KcabCESEFl8Gq6aRi9vAfa65oxD5jKaIz7cy+pwb0lizqlW7H9t Qlr3dBfdIcdzgR55hTFC5/XrcwJ6/nHVH/xGskEasnfCQX8RYKMuy0UADJy72TkZ bYaCx+XXIcVB8GTOmJVoAhrTSSVLAZspfCnjwnSxisDn3ZzsYrq3cV6sU8b+QlIX 7VAjurE+5cZiVlaxgCjyhKqlGgmonnReWOBacCgL/UvuwMmMp5TTLmiLXLT7uxeG ojEyoCk4sMrqrU1jevHyGlDJH9Taux15GILDwnYFfAvPF9WCid4UZ4Ouwjcaxfys 3LxNiZIlUsXNKwS3mhiMRL4TRsbs4k4QE+LIMOsauIvcvm8/frydvQ/kUwIhVTH8 0XGOH909bYtJvY3fudK7ShIwm7ZFTduBJUG473E/Fn3VkhTmBX6+PjOC50HR/Hyb waRCzfDruMe3TAcE/tSP5CUOb9C7+P+hPzQcDwARAQABiQRyBBgBCgAmFiEEyHQB Hwq0BRENAhBVNDZdlHLXRo8FAmCAXCYCGwIFCQlmAYACQAkQNDZdlHLXRo/BdCAE GQEKAB0WIQQ3TsdbSFkTYEqDHMfIIMbVzSerhwUCYIBcJgAKCRDIIMbVzSerh0Xw D/9ghnUsoNCu1OulcoJdHboMazJvDt/znttdQSnULBVElgM5zk0Uyv87zFBzuCyQ JWL3bWesQ2uFx5fRWEPDEfWVdDrjpQGb1OCCQyz1QlNPV/1M1/xhKGS9EeXrL8Dw F6KTGkRwn1yXiP4BGgfeFIQHmJcKXEZ9HkrpNb8mcexkROv4aIPAwn+IaE+NHVtt IBnufMXLyfpkWJQtJa9elh9PMLlHHnuvnYLvuAoOkhuvs7fXDMpfFZ01C+QSv1dz Hm52GSStERQzZ51w4c0rYDneYDniC/sQT1x3dP5Xf6wzO+EhRMabkvoTbMqPsTEP xyWr2pNtTBYp7pfQjsHxhJpQF0xjGN9C39z7f3gJG8IJhnPeulUqEZjhRFyVZQ6/ siUeq7vu4+dM/JQL+i7KKe7Lp9UMrG6NLMH+ltaoD3+lVm8fdTUxS5MNPoA/I8cK 1OWTJHkrp7V/XaY7mUtvQn5V1yET5b4bogz4nME6WLiFMd+7x73gB+YJ6MGYNuO8 e/NFK67MfHbk1/AiPTAJ6s5uHRQIkZcBPG7y5PpfcHpIlwPYCDGYlTajZXblyKrw BttVnYKvKsnlysv11glSg0DphGxQJbXzWpvBNyhMNH5dffcfvd3eXJAxnD81GD2z ZAriMJ4Av2TfeqQ2nxd2ddn0jX4WVHtAvLXfCgLM2Gveho4jD/9sZ6PZz/rEeTvt h88t50qPcBa4bb25X0B5FO3TeK2LL3VKLuEp5lgdcHVonrcdqZFobN1CgGJua8TW SprIkh+8ATZ/FXQTi01NzLhHXT1IQzSpFaZw0gb2f5ruXwvTPpfXzQrs2omY+7s7 fkCwGPesvpSXPKn9v8uhUwD7NGW/Dm+jUM+QtC/FqzX7+/Q+OuEPjClUh1cqopCZ EvAI3HjnavGrYuU6DgQdjyGT/UDbuwbCXqHxHojVVkISGzCTGpmBcQYQqhcFRedJ yJlu6PSXlA7+8Ajh52oiMJ3ez4xSssFgUQAyOB16432tm4erpGmCyakkoRmMUn3p wx+QIppxRlsHznhcCQKR3tcblUqH3vq5i4/ZAihusMCa0YrShtxfdSb13oKX+pFr aZXvxyZlCa5qoQQBV1sowmPL1N2j3dR9TVpdTyCFQSv4KeiExmowtLIjeCppRBEK eeYHJnlfkyKXPhxTVVO6H+dU4nVu0ASQZ07KiQjbI+zTpPKFLPp3/0sPRJM57r1+ aTS71iR7nZNZ1f8LZV2OvGE6fJVtgJ1J4Nu02K54uuIhU3tg1+7Xt+IqwRc9rbVr pHH/hFCYBPW2D2dxB+k2pQlg5NI+TpsXj5Zun8kRw5RtVb+dLuiH/xmxArIee8Jq ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== =7pIB -----END PGP PUBLIC KEY BLOCK-----` // HashicorpPartnersKey is a key created by HashiCorp, used to generate and // verify trust signatures for Partner tier providers. const HashicorpPartnersKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- mQINBF5vdGkBEADKi3Nm83oqMcar+YSDFKBup7+/Ty7m+SldtDH4/RWT0vgVHuQ1 0joA+TrjITR5/aBVQ1/i2pOiBiImnaWsykccjFw9f9AuJqHo520YrAbNCeA6LuGH Gvz4u0ReL/Cjbb9xCb34tejmrVOX+tmyiYBQd+oTae3DiyffOI9HxF6v+IKhOFKz Grs3/R5MDwU1ZQIXTO2bdBOM67XBwvTUC+dy6Nem5UmmwuCI0Qz/JWTGndG8aGDC EO9+DJ59/IwzBYlbs11iqdfqiGALNr+4FXTwftsxZOGpyxhjyAK00U2PP+gQ/wOK aeIOL7qpF94GdyVrZzDeMKVLUDmhXxDhyatG4UueRJVAoqNVvAFfEwavpYUrVpYl se/ZugCcTc9VeDodA4r4VI8yQQW805C+uZ/Q+Ym4r+xTsKcTyC4er4ogXgrMT73B 9sgA2M1B4oGbMN5IuG/L2C9JZ1Tob0h0fX+UGMOvrpWeJkZEKTU8hm4mZwhxeRdL rrcqs6sewNPRnSiUlxz9ynJuf8vFNAD79Z6H9lULe6FnPuLImzH78FKH9QMQsoAW z1GlYDrxNs3rHDTkSmvglwmWKpsfCxUnfq4ecsYtroCDjAwhLsf2qO1WlXD8B53h 6LU5DwPo7jJDpOv4B0YbjGuAJCf0oXmhXqdu9te6ybXb84ArtHlVO4EBRQARAQAB tFFIYXNoaUNvcnAgU2VjdXJpdHkgKFRlcnJhZm9ybSBQYXJ0bmVyIFNpZ25pbmcp IDxzZWN1cml0eSt0ZXJyYWZvcm1AaGFzaGljb3JwLmNvbT6JAk4EEwEIADgWIQRR iQZXxazbS4IwhlZ9ctQmjkZg/AUCXm90aQIbAwULCQgHAgYVCgkICwIEFgIDAQIe AQIXgAAKCRB9ctQmjkZg/LxFEACACTHlqULv38VCteo8UR4sRFcaSK4kwzXyRLI2 oi3tnGdzc9AJ5Brp6/GwcERz0za3NU6LJ5kI7umHhuSb+FOjzQKLbttfKL+bTiNH HY9NyJPhr6wKJs4Mh8HJ7/FdU7Tsg0cpayNvO5ilU3Mf7H1zaWOVut8BFRYqXGKi K5/GGmw9C6QwaVSxR4i2kcZYUk4mnTikug53/4sQGnD3zScpDjipEqGTBMLk4r+E 0792MZFRAYRIMmZ0NfaMoIGE7bnmtMrbqtNiw+VaPILk6EyDVK3XJxNDBY/4kwHW 4pDa/qjD7nCL7LapP6NN8sDE++l2MSveorzjtR2yV+goqK1yV0VL2X8zwk1jANX7 HatY6eKJwkx72BpL5N3ps915Od7kc/k7HdDgyoFQCOkuz9nHr7ix1ioltDcaEXwQ qTv33M21uG7muNlFsEav2yInPGmIRRqBaGg/5AjF8v1mnGOjzJKNMCIEXIpkYoPS fY9wud2s9DvHHvVuF+pT8YtmJDqKdGVAgv+VAH8z6zeIRaQXRRrbzFaCIozmz3qF RLPixaPhcw5EHB7MhWBVDnsPXJG811KjMxCrW57ldeBsbR+cEKydEpYFnSjwksGy FrCFPA4Vol/ks/ldotS7P9FDmYs7VfB0fco4fdyvwnxksRCfY1kg0dJA3Q0uj/uD MoBzF7kCDQReb3RpARAAr1uZ2iRuoFRTBiI2Ao9Mn2Nk0B+WEWT+4S6oDSuryf+6 sKI9Z+wgSvp7DOKyNARoqv+hnjA5Z+t7y/2K7fZP4TYpqOKw8NRKIUoNH0U2/YED LN0FlXKuVdXtqfijoRZF/W/UyEMVRpub0yKwQDgsijoUDXIG1INVO/NSMGh5UJxE I+KoU+oIahNPSTgHPizqhJ5OEYkMMfvIr5eHErtB9uylqifVDlvojeHyzU46XmGw QLxYzufzLYoeBx9uZjZWIlxpxD2mVPmAYVJtDE0uKRZ29+fnlcxWzhx7Ow+wSVRp XLwDLxZh1YJseY/cGj6yzjA8NolG1fx94PRD1iF7VukHJ3LkukK3+Iw2o4JKmrFx FpVVcEoldb4bNRMnbY0KDOXn0/9LM+lhEnCRAo8y5zDO6kmjA56emy4iPHRBlngJ Egms8wnuKsgNkYG8uRaa6zC9FOY/4MbXtNPg8j3pPlWr5jQVdy053uB9UqGs7y3a C1z9bII58Otp8p4Hf5W97MNuXTxPgPDNmWXA6xu7k2+aut8dgvgz1msHTs31bTeG X4iRt23/XWlIy56Jar6NkV74rdiKevAbJRHp/sj9AIR4h0pm4yCjZSEKmMqELj7L nVSj0s9VSL0algqK5yXLoj6gYUWFfcuHcypnRGvjrpDzGgD9AKrDsmQ3pxFflZ8A EQEAAYkCNgQYAQgAIBYhBFGJBlfFrNtLgjCGVn1y1CaORmD8BQJeb3RpAhsMAAoJ EH1y1CaORmD89rUP/0gszqvnU3oXo1lMiwz44EfHDGWeY6sh1pJS0FfyjefIMEzE rAJvyWXbzRj+Dd2g7m7p5JUf/UEMO6EFdxe1l6IihHJBs+pC6hliFwlGosfJwVc2 wtPg6okAfFI35RBedvrV3uzq01dqFlb+d85Gl24du6nOv6eBXiZ8Pr9F3zPDHLPw DTP/RtNDxnw8KOC0Z0TE9iQIY1rJCI2mekJ4btHRQ2q9eZQjGFp5HcHBXs/D2ZXC H/vwB0UskHrtduEUSeTgKkKuPuxbCU5rhE8RGprS41KLYozveD0r5BPa9kBx7qYZ iOHgWfwlJ4yRjgjtoZl4E9/7aGioYycHNG26UZ+ZHgwTwtDrTU+LP89WrhzoOQmq H0oU4P/oMe2YKnG6FgCWt8h+31Q08G5VJeXNUoOn+RG02M7HOMHYGeP5wkzAy2HY I4iehn+A3Cwudv8Gh6WaRqPjLGbk9GWr5fAUG3KLUgJ8iEqnt0/waP7KD78TVId8 DgHymHMvAU+tAxi5wUcC3iQYrBEc1X0vcsRcW6aAi2Cxc/KEkVCz+PJ+HmFVZakS V+fniKpSnhUlDkwlG5dMGhkGp/THU3u8oDb3rSydRPcRXVe1D0AReUFE2rDOeRoT VYF2OtVmpc4ntcRyrItyhSkR/m7BQeBFIT8GQvbTmrCDQgrZCsFsIwxd4Cb4 =5/s+ -----END PGP PUBLIC KEY BLOCK-----` ================================================ FILE: internal/tf/getproviders/testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64/extra-data.txt ================================================ Provider plugin packages are allowed to include other files such as any static data they need to operate, or possibly source files if the provider is written in an interpreted programming language. This extra file is here just to make sure that extra files don't cause any misbehavior during local discovery. ================================================ FILE: internal/tf/getproviders/testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64/terraform-provider-happycloud ================================================ # This is just a placeholder file for discovery testing, not a real provider plugin. ================================================ FILE: internal/tf/getter.go ================================================ package tf import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path" "path/filepath" "strings" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/go-getter" safetemp "github.com/hashicorp/go-safetemp" svchost "github.com/hashicorp/terraform-svchost" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/tf/cliconfig" "github.com/gruntwork-io/terragrunt/internal/util" ) // httpClient is the default client to be used by HttpGetters. var httpClient = cleanhttp.DefaultClient() // Constants relevant to the module registry const ( defaultRegistryDomain = "registry.terraform.io" defaultOtRegistryDomain = "registry.opentofu.org" serviceDiscoveryPath = "/.well-known/terraform.json" versionQueryKey = "version" authTokenEnvName = "TG_TF_REGISTRY_TOKEN" defaultRegistryEnvName = "TG_TF_DEFAULT_REGISTRY_HOST" ) // RegistryServicePath is a struct for extracting the modules service path in the Registry. type RegistryServicePath struct { ModulesPath string `json:"modules.v1"` } // RegistryGetter is a Getter (from go-getter) implementation that will download from the terraform module // registry. This supports getter URLs encoded in the following manner: // // tfr://REGISTRY_DOMAIN/MODULE_PATH?version=VERSION // // Where the REGISTRY_DOMAIN is the terraform registry endpoint (e.g., registry.terraform.io), MODULE_PATH is the // registry path for the module (e.g., terraform-aws-modules/vpc/aws), and VERSION is the specific version of the module // to download (e.g., 2.2.0). // // This protocol will use the Module Registry Protocol (documented at // https://www.terraform.io/docs/internals/module-registry-protocol.html) to lookup the module source URL and download // it. // // Authentication to private module registries is handled via environment variables. The authorization API token is // expected to be provided to Terragrunt via the TG_TF_REGISTRY_TOKEN environment variable. This token can be any // registry API token generated on Terraform Cloud / Enterprise. // // MAINTAINER'S NOTE: Ideally we implement the full credential system that terraform uses as part of `terraform login`, // but all the relevant packages are internal to the terraform repository, thus making it difficult to use as a // library. For now, we keep things simple by supporting providing tokens via env vars and in the future, we can // consider implementing functionality to load credentials from terraform. // GH issue: https://github.com/gruntwork-io/terragrunt/issues/1771 // // MAINTAINER'S NOTE: Ideally we can support a shorthand notation that omits the tfr:// protocol to detect that it is // referring to a terraform registry, but this requires implementing a complex detector and ensuring it has precedence // over the file detector. We deferred the implementation for that to a future release. // GH issue: https://github.com/gruntwork-io/terragrunt/issues/1772 type RegistryGetter struct { client *getter.Client Logger log.Logger TofuImplementation tfimpl.Type } // SetClient allows the getter to know what getter client (different from the underlying HTTP client) to use for // progress tracking. func (tfrGetter *RegistryGetter) SetClient(client *getter.Client) { tfrGetter.client = client } // Context returns the go context to use for the underlying fetch routines. This depends on what client is set. func (tfrGetter *RegistryGetter) Context() context.Context { if tfrGetter == nil || tfrGetter.client == nil { return context.Background() } return tfrGetter.client.Ctx } // registryDomain returns the default registry domain to use for the getter. func (tfrGetter *RegistryGetter) registryDomain() string { return GetDefaultRegistryDomain(tfrGetter.TofuImplementation) } // GetDefaultRegistryDomain returns the appropriate registry domain based on the terraform implementation and environment variables. // This is the canonical function for determining which registry to use throughout Terragrunt. func GetDefaultRegistryDomain(impl tfimpl.Type) string { // if is set TG_TF_DEFAULT_REGISTRY env var, use it as default registry if defaultRegistry := os.Getenv(defaultRegistryEnvName); defaultRegistry != "" { return defaultRegistry } // if binary is set to use OpenTofu registry, use OpenTofu as default registry if impl == tfimpl.OpenTofu { return defaultOtRegistryDomain } return defaultRegistryDomain } // ClientMode returns the download mode based on the given URL. Since this getter is designed around the Terraform // module registry, we always use Dir mode so that we can download the full Terraform module. func (tfrGetter *RegistryGetter) ClientMode(u *url.URL) (getter.ClientMode, error) { return getter.ClientModeDir, nil } // Get is the main routine to fetch the module contents specified at the given URL and download it to the dstPath. // This routine assumes that the srcURL points to the Terraform registry URL, with the Path configured to the module // path encoded as `:namespace/:name/:system` as expected by the Terraform registry. Note that the URL query parameter // must have the `version` key to specify what version to download. func (tfrGetter *RegistryGetter) Get(dstPath string, srcURL *url.URL) error { ctx := tfrGetter.Context() registryDomain := srcURL.Host if registryDomain == "" { registryDomain = tfrGetter.registryDomain() } queryValues := srcURL.Query() modulePath, moduleSubDir := getter.SourceDirSubdir(srcURL.Path) versionList, hasVersion := queryValues[versionQueryKey] if !hasVersion { return errors.New(MalformedRegistryURLErr{reason: "missing version query"}) } if len(versionList) != 1 { return errors.New(MalformedRegistryURLErr{reason: "more than one version query"}) } version := versionList[0] moduleRegistryBasePath, err := GetModuleRegistryURLBasePath(ctx, tfrGetter.Logger, registryDomain) if err != nil { return err } moduleURL, err := BuildRequestURL(registryDomain, moduleRegistryBasePath, modulePath, version) if err != nil { return err } terraformGet, err := GetTerraformGetHeader(ctx, tfrGetter.Logger, moduleURL) if err != nil { return err } downloadURL, err := GetDownloadURLFromHeader(moduleURL, terraformGet) if err != nil { return err } // If there is a subdir component, then we download the root separately into a temporary directory, then copy over // the proper subdir. Note that we also have to take into account sub dirs in the original URL in addition to the // subdir component in the X-Terraform-Get download URL. source, subDir := getter.SourceDirSubdir(downloadURL) if subDir == "" && moduleSubDir == "" { var opts []getter.ClientOption if tfrGetter.client != nil { opts = tfrGetter.client.Options } return getter.Get(dstPath, source, opts...) } // We have a subdir, time to jump some hoops return tfrGetter.getSubdir(ctx, tfrGetter.Logger, dstPath, source, path.Join(subDir, moduleSubDir)) } // GetFile is not implemented for the Terraform module registry Getter since the terraform module registry doesn't serve // a single file. func (tfrGetter *RegistryGetter) GetFile(dst string, src *url.URL) error { return errors.New("GetFile is not implemented for the Terraform Registry Getter") } // getSubdir downloads the source into the destination, but with the proper subdir. func (tfrGetter *RegistryGetter) getSubdir(_ context.Context, l log.Logger, dstPath, sourceURL, subDir string) error { // Create a temporary directory to store the full source. This has to be a non-existent directory. tempdirPath, tempdirCloser, err := safetemp.Dir("", "getter") if err != nil { return err } defer func(tempdirCloser io.Closer) { err := tempdirCloser.Close() if err != nil { l.Warnf("Error closing temporary directory %s: %v", tempdirPath, err) } }(tempdirCloser) var opts []getter.ClientOption if tfrGetter.client != nil { opts = tfrGetter.client.Options } // Download that into the given directory if err := getter.Get(tempdirPath, sourceURL, opts...); err != nil { return errors.New(err) } // Process any globbing sourcePath, err := getter.SubdirGlob(tempdirPath, subDir) if err != nil { return errors.New(err) } // Make sure the subdir path actually exists if _, err := os.Stat(sourcePath); err != nil { details := fmt.Sprintf("could not stat download path %s (error: %s)", sourcePath, err) return errors.New(ModuleDownloadErr{sourceURL: sourceURL, details: details}) } // Copy the subdirectory into our actual destination. if err := os.RemoveAll(dstPath); err != nil { return errors.New(err) } // Make the final destination const ownerWriteGlobalReadExecutePerms = 0755 if err := os.MkdirAll(dstPath, ownerWriteGlobalReadExecutePerms); err != nil { return errors.New(err) } // We use a temporary manifest file here that is deleted at the end of this routine since we don't intend to come // back to it. manifestFname := ".tgmanifest" manifestPath := filepath.Join(dstPath, manifestFname) defer func(name string) { err := os.Remove(name) if err != nil { l.Warnf("Error removing temporary directory %s: %v", name, err) } }(manifestPath) return util.CopyFolderContentsWithFilter(l, sourcePath, dstPath, manifestFname, func(path string) bool { return true }) } // GetModuleRegistryURLBasePath uses the service discovery protocol // (https://www.terraform.io/docs/internals/remote-service-discovery.html) // to figure out where the modules are stored. This will return the base // path where the modules can be accessed func GetModuleRegistryURLBasePath(ctx context.Context, logger log.Logger, domain string) (string, error) { sdURL := url.URL{ Scheme: "https", Host: domain, Path: serviceDiscoveryPath, } bodyData, _, err := httpGETAndGetResponse(ctx, logger, &sdURL) if err != nil { return "", err } var respJSON RegistryServicePath if err := json.Unmarshal(bodyData, &respJSON); err != nil { reason := fmt.Sprintf("Error parsing response body %s: %s", string(bodyData), err) return "", errors.New(ServiceDiscoveryErr{reason: reason}) } return respJSON.ModulesPath, nil } // GetTerraformGetHeader makes an http GET call to the given registry URL and return the contents of location json // body or the header X-Terraform-Get. This function will return an error if the response does not contain the header. func GetTerraformGetHeader(ctx context.Context, logger log.Logger, url *url.URL) (string, error) { body, header, err := httpGETAndGetResponse(ctx, logger, url) if err != nil { details := "error receiving HTTP data" return "", errors.New(ModuleDownloadErr{sourceURL: url.String(), details: details}) } terraformGet := header.Get("X-Terraform-Get") if terraformGet != "" { return terraformGet, nil } // parse response from body as json var responseJSON map[string]string if err := json.Unmarshal(body, &responseJSON); err != nil { reason := fmt.Sprintf("Error parsing response body %s: %s", string(body), err) return "", errors.New(ModuleDownloadErr{sourceURL: url.String(), details: reason}) } // get location value from responseJSON terraformGet = responseJSON["location"] if terraformGet != "" { return terraformGet, nil } if terraformGet == "" { details := "no source URL was returned in header X-Terraform-Get and in location response from download URL" return "", errors.New(ModuleDownloadErr{sourceURL: url.String(), details: details}) } return terraformGet, nil } // GetDownloadURLFromHeader checks if the content of the X-Terraform-GET header contains the base url // and prepends it if not func GetDownloadURLFromHeader(moduleURL *url.URL, terraformGet string) (string, error) { // If url from X-Terrafrom-Get Header seems to be a relative url, // append scheme and host from url used for getting the download url // because third-party registry implementations may not "know" their own absolute URLs if // e.g. they are running behind a reverse proxy frontend, or such. if strings.HasPrefix(terraformGet, "/") || strings.HasPrefix(terraformGet, "./") || strings.HasPrefix(terraformGet, "../") { relativePathURL, err := url.Parse(terraformGet) if err != nil { return "", errors.New(err) } terraformGetURL := moduleURL.ResolveReference(relativePathURL) terraformGet = terraformGetURL.String() } return terraformGet, nil } func applyHostToken(req *http.Request) (*http.Request, error) { cliCfg, err := cliconfig.LoadUserConfig() if err != nil { return nil, err } if creds := cliCfg.CredentialsSource().ForHost(svchost.Hostname(req.URL.Hostname())); creds != nil { creds.PrepareRequest(req) } else { // fall back to the TG_TF_REGISTRY_TOKEN authToken := os.Getenv(authTokenEnvName) if authToken != "" { req.Header.Add("Authorization", "Bearer "+authToken) } } return req, nil } // httpGETAndGetResponse is a helper function to make a GET request to the given URL using the http client. This // function will then read the response and return the contents + the response header. func httpGETAndGetResponse(ctx context.Context, logger log.Logger, getURL *url.URL) ([]byte, *http.Header, error) { if getURL == nil { return nil, nil, errors.New("httpGETAndGetResponse received nil getURL") } req, err := http.NewRequestWithContext(ctx, "GET", getURL.String(), nil) if err != nil { return nil, nil, errors.New(err) } // Handle authentication via env var. Authentication is done by providing the registry token as a bearer token in // the request header. req, err = applyHostToken(req) if err != nil { return nil, nil, errors.New(err) } resp, err := httpClient.Do(req) if err != nil { return nil, nil, errors.New(err) } defer func() { err := resp.Body.Close() if err != nil { logger.Warnf("Error closing response body: %v", err) } }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, nil, errors.New(RegistryAPIErr{url: getURL.String(), statusCode: resp.StatusCode}) } bodyData, err := io.ReadAll(resp.Body) return bodyData, &resp.Header, errors.New(err) } // BuildRequestURL - create url to download module using moduleRegistryBasePath func BuildRequestURL(registryDomain string, moduleRegistryBasePath string, modulePath string, version string) (*url.URL, error) { moduleRegistryBasePath = strings.TrimSuffix(moduleRegistryBasePath, "/") modulePath = strings.TrimSuffix(modulePath, "/") modulePath = strings.TrimPrefix(modulePath, "/") moduleFullPath := fmt.Sprintf("%s/%s/%s/download", moduleRegistryBasePath, modulePath, version) moduleURL, err := url.Parse(moduleFullPath) if err != nil { return nil, err } if moduleURL.Scheme != "" { return moduleURL, nil } return &url.URL{Scheme: "https", Host: registryDomain, Path: moduleFullPath}, nil } ================================================ FILE: internal/tf/getter_test.go ================================================ package tf_test import ( "net/url" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetModuleRegistryURLBasePath(t *testing.T) { t.Parallel() basePath, err := tf.GetModuleRegistryURLBasePath(t.Context(), logger.CreateLogger(), "registry.terraform.io") require.NoError(t, err) assert.Equal(t, "/v1/modules/", basePath) } func TestGetTerraformHeader(t *testing.T) { t.Parallel() testModuleURL := url.URL{ Scheme: "https", Host: "registry.terraform.io", Path: "/v1/modules/terraform-aws-modules/vpc/aws/3.3.0/download", } terraformGetHeader, err := tf.GetTerraformGetHeader(t.Context(), logger.CreateLogger(), &testModuleURL) require.NoError(t, err) assert.Contains(t, terraformGetHeader, "github.com/terraform-aws-modules/terraform-aws-vpc") } func TestGetDownloadURLFromHeader(t *testing.T) { t.Parallel() testCases := []struct { name string terraformGet string expectedResult string moduleURL url.URL }{ { name: "BaseWithRoot", moduleURL: url.URL{ Scheme: "https", Host: "registry.terraform.io", }, terraformGet: "/terraform-aws-modules/terraform-aws-vpc", expectedResult: "https://registry.terraform.io/terraform-aws-modules/terraform-aws-vpc", }, { name: "PrefixedURL", moduleURL: url.URL{}, terraformGet: "github.com/terraform-aws-modules/terraform-aws-vpc", expectedResult: "github.com/terraform-aws-modules/terraform-aws-vpc", }, { name: "PathWithRoot", moduleURL: url.URL{ Scheme: "https", Host: "registry.terraform.io", Path: "modules/foo/bar", }, terraformGet: "/terraform-aws-modules/terraform-aws-vpc", expectedResult: "https://registry.terraform.io/terraform-aws-modules/terraform-aws-vpc", }, { name: "PathWithRelativeRoot", moduleURL: url.URL{ Scheme: "https", Host: "registry.terraform.io", Path: "modules/foo/bar", }, terraformGet: "./terraform-aws-modules/terraform-aws-vpc", expectedResult: "https://registry.terraform.io/modules/foo/terraform-aws-modules/terraform-aws-vpc", }, { name: "PathWithRelativeParent", moduleURL: url.URL{ Scheme: "https", Host: "registry.terraform.io", Path: "modules/foo/bar", }, terraformGet: "../terraform-aws-modules/terraform-aws-vpc", expectedResult: "https://registry.terraform.io/modules/terraform-aws-modules/terraform-aws-vpc", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() downloadURL, err := tf.GetDownloadURLFromHeader(&tc.moduleURL, tc.terraformGet) require.NoError(t, err) assert.Equal(t, tc.expectedResult, downloadURL) }) } } func TestTFRGetterRootDir(t *testing.T) { t.Parallel() testModuleURL, err := url.Parse("tfr://registry.terraform.io/terraform-aws-modules/vpc/aws?version=3.3.0") require.NoError(t, err) dstPath := helpers.TmpDirWOSymlinks(t) // The dest path must not exist for go getter to work moduleDestPath := filepath.Join(dstPath, "terraform-aws-vpc") assert.False(t, util.FileExists(filepath.Join(moduleDestPath, "main.tf"))) tfrGetter := new(tf.RegistryGetter) tfrGetter.TofuImplementation = tfimpl.Terraform require.NoError(t, tfrGetter.Get(moduleDestPath, testModuleURL)) assert.True(t, util.FileExists(filepath.Join(moduleDestPath, "main.tf"))) } func TestTFRGetterSubModule(t *testing.T) { t.Parallel() testModuleURL, err := url.Parse("tfr://registry.terraform.io/terraform-aws-modules/vpc/aws//modules/vpc-endpoints?version=3.3.0") require.NoError(t, err) dstPath := helpers.TmpDirWOSymlinks(t) // The dest path must not exist for go getter to work moduleDestPath := filepath.Join(dstPath, "terraform-aws-vpc") assert.False(t, util.FileExists(filepath.Join(moduleDestPath, "main.tf"))) tfrGetter := new(tf.RegistryGetter) tfrGetter.TofuImplementation = tfimpl.Terraform require.NoError(t, tfrGetter.Get(moduleDestPath, testModuleURL)) assert.True(t, util.FileExists(filepath.Join(moduleDestPath, "main.tf"))) } func TestBuildRequestUrlFullPath(t *testing.T) { t.Parallel() requestURL, err := tf.BuildRequestURL("gruntwork.io", "https://gruntwork.io/registry/modules/v1/", "/tfr-project/terraform-aws-tfr", "6.6.6") require.NoError(t, err) assert.Equal(t, "https://gruntwork.io/registry/modules/v1/tfr-project/terraform-aws-tfr/6.6.6/download", requestURL.String()) } func TestBuildRequestUrlRelativePath(t *testing.T) { t.Parallel() requestURL, err := tf.BuildRequestURL("gruntwork.io", "/registry/modules/v1", "/tfr-project/terraform-aws-tfr", "6.6.6") require.NoError(t, err) assert.Equal(t, "https://gruntwork.io/registry/modules/v1/tfr-project/terraform-aws-tfr/6.6.6/download", requestURL.String()) } ================================================ FILE: internal/tf/log.go ================================================ package tf import ( "regexp" "strings" "time" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/writer" ) const parseLogNumberOfValues = 4 var ( // logTimestampFormat is TF_LOG timestamp formats. logTimestampFormat = "2006-01-02T15:04:05.000Z0700" ) var ( // tfLogTimeLevelMsgReg is a regular expression that matches TF_LOG output, example output: // // 2024-09-08T13:44:31.229+0300 [DEBUG] using github.com/zclconf/go-cty v1.14.3 // 2024-09-08T13:44:31.229+0300 [INFO] Go runtime version: go1.22.1 tfLogTimeLevelMsgReg = regexp.MustCompile(`(?i)(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\S*)\s*\[(trace|debug|warn|info|error)\]\s*(.+\S)$`) ) // ParseLogFunc wraps `ParseLog` to add msg prefix and bypasses the parse error if `returnError` is false, // since returning the error for `log/writer` will cause TG to fall with a `broken pipe` error. func ParseLogFunc(msgPrefix string, returnError bool) writer.WriterParseFunc { return func(str string) (msg string, ptrTime *time.Time, ptrLevel *log.Level, err error) { if msg, ptrTime, ptrLevel, err = ParseLog(str); err != nil { if returnError { return str, nil, nil, err } return str, nil, nil, nil } return msgPrefix + msg, ptrTime, ptrLevel, nil } } func ParseLog(str string) (msg string, ptrTime *time.Time, ptrLevel *log.Level, err error) { if !tfLogTimeLevelMsgReg.MatchString(str) { return str, nil, nil, errors.Errorf("could not parse string %q: does not match a known format", str) } match := tfLogTimeLevelMsgReg.FindStringSubmatch(str) if len(match) != parseLogNumberOfValues { return str, nil, nil, errors.Errorf("could not parse string %q: does not match a known format", str) } timeStr, levelStr, msg := match[1], match[2], match[3] if levelStr != "" { level, err := log.ParseLevel(strings.ToLower(levelStr)) if err != nil { return str, nil, nil, errors.Errorf("could not parse level %q: %w", levelStr, err) } ptrLevel = &level } if timeStr != "" { time, err := time.Parse(logTimestampFormat, timeStr) if err != nil { return str, nil, nil, errors.Errorf("could not parse time %q: %w", timeStr, err) } ptrTime = &time } return msg, ptrTime, ptrLevel, nil } ================================================ FILE: internal/tf/run_cmd.go ================================================ package tf import ( "context" "io" "os" "path/filepath" "slices" "github.com/gruntwork-io/go-commons/collections" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" logwriter "github.com/gruntwork-io/terragrunt/pkg/log/writer" "github.com/mattn/go-isatty" ) const ( // tfLogMsgPrefix is a message prefix that is prepended to each TF_LOG output lines when the output is integrated in TG log, for example: // // TF_LOG: using github.com/zclconf/go-cty v1.14.3 // TF_LOG: Go runtime version: go1.22.1 tfLogMsgPrefix = "TF_LOG: " logMsgSeparator = "\n" defaultWriterOptionsLen = 2 ) // Commands that implement a REPL need a pseudo TTY when run as a subprocess in order for the readline properties to be // preserved. This is a list of terraform commands that have this property, which is used to determine if terragrunt // should allocate a ptty when running that terraform command. var commandsThatNeedPty = []string{ CommandNameConsole, } // TFOptions contains the configuration needed to run TF commands. type TFOptions struct { ShellOptions *shell.ShellOptions TerraformCliArgs *iacargs.IacArgs TerragruntConfigPath string TofuImplementation tfimpl.Type OriginalTerragruntConfigPath string JSONLogFormat bool } // RunCommand runs the given Terraform command. func RunCommand(ctx context.Context, l log.Logger, runOpts *TFOptions, args ...string) error { _, err := RunCommandWithOutput(ctx, l, runOpts, args...) return err } // RunCommandWithOutput runs the given Terraform command, writing its stdout/stderr to the terminal AND returning stdout/stderr to this // method's caller func RunCommandWithOutput(ctx context.Context, l log.Logger, runOpts *TFOptions, args ...string) (*util.CmdOutput, error) { args = clihelper.Args(args).Normalize(clihelper.SingleDashFlag) if fn := TerraformCommandHookFromContext(ctx); fn != nil { return fn(ctx, l, runOpts, args) } needsPTY, err := isCommandThatNeedsPty(args) if err != nil { return nil, err } shellOpts := runOpts.ShellOptions if !runOpts.ShellOptions.ForwardTFStdout { // Copy the shell opts to avoid mutating the caller's struct. shellOptsCopy := *shellOpts shellOpts = &shellOptsCopy outWriter, errWriter := logTFOutput(l, runOpts, args) shellOpts.Writers.Writer = outWriter shellOpts.Writers.ErrWriter = errWriter } output, err := shell.RunCommandWithOutput(ctx, l, shellOpts, "", false, needsPTY, runOpts.ShellOptions.TFPath, args...) hasDetailedExitCode := slices.Contains(args, FlagNameDetailedExitCode) if hasDetailedExitCode { code := 0 if err != nil { code, _ = util.GetExitCode(err) } if exitCode := DetailedExitCodeFromContext(ctx); exitCode != nil { exitCode.Set(filepath.Dir(runOpts.OriginalTerragruntConfigPath), code) } if code != 1 { return output, nil } } return output, err } func logTFOutput(l log.Logger, runOpts *TFOptions, args clihelper.Args) (io.Writer, io.Writer) { var ( originalOutWriter = writer.NewOriginalWriter(runOpts.ShellOptions.Writers.Writer) originalErrWriter = writer.NewOriginalWriter(runOpts.ShellOptions.Writers.ErrWriter) outWriter io.Writer = originalOutWriter errWriter io.Writer = originalErrWriter ) logger := l. WithField(placeholders.TFPathKeyName, filepath.Base(runOpts.ShellOptions.TFPath)). WithField(placeholders.TFCmdArgsKeyName, args.Slice()). WithField(placeholders.TFCmdKeyName, args.CommandName()) if runOpts.JSONLogFormat && !args.Normalize(clihelper.SingleDashFlag).Contains(FlagNameJSON) { wrappedOut := buildOutWriter( logger, runOpts.ShellOptions.Headless, outWriter, errWriter, ) wrappedErr := buildErrWriter( logger, runOpts.ShellOptions.Headless, errWriter, ) outWriter = writer.NewWrappedWriter(wrappedOut, originalOutWriter) errWriter = writer.NewWrappedWriter(wrappedErr, originalErrWriter) } else if !shouldForceForwardTFStdout(args) { wrappedOut := buildOutWriter( logger, runOpts.ShellOptions.Headless, outWriter, errWriter, logwriter.WithMsgSeparator(logMsgSeparator), ) wrappedErr := buildErrWriter( logger, runOpts.ShellOptions.Headless, errWriter, logwriter.WithMsgSeparator(logMsgSeparator), logwriter.WithParseFunc(ParseLogFunc(tfLogMsgPrefix, false)), ) outWriter = writer.NewWrappedWriter(wrappedOut, originalOutWriter) errWriter = writer.NewWrappedWriter(wrappedErr, originalErrWriter) } return outWriter, errWriter } // isCommandThatNeedsPty returns true if the sub command of terraform we are running requires a pty. func isCommandThatNeedsPty(args []string) (bool, error) { if len(args) == 0 || !slices.Contains(commandsThatNeedPty, args[0]) { return false, nil } fi, err := os.Stdin.Stat() if err != nil { return false, errors.New(err) } // if there is data in the stdin, then the terraform console is used in non-interactive mode, for example `echo "1 + 5" | terragrunt console`. if fi.Size() > 0 { return false, nil } // if the stdin is not a terminal, then the terraform console is used in non-interactive mode if !isatty.IsTerminal(os.Stdin.Fd()) { return false, nil } return true, nil } // shouldForceForwardTFStdout returns true if at least one of the conditions is met, args contains the `-json` flag or the `output` or `state` command. func shouldForceForwardTFStdout(args clihelper.Args) bool { tfCommands := []string{ CommandNameOutput, CommandNameState, CommandNameVersion, CommandNameConsole, CommandNameGraph, } tfFlags := []string{ FlagNameJSON, FlagNameVersion, FlagNameHelpLong, FlagNameHelpShort, } if slices.ContainsFunc(tfFlags, args.Normalize(clihelper.SingleDashFlag).Contains) { return true } return collections.ListContainsElement(tfCommands, args.CommandName()) } // buildOutWriter returns the writer for the command's stdout. // // When Terragrunt is running in Headless mode, we want to forward // any stdout to the INFO log level, otherwise, we want to forward // stdout to the STDOUT log level. // // Also accepts any additional writer options desired. func buildOutWriter(l log.Logger, headless bool, outWriter, errWriter io.Writer, writerOptions ...logwriter.Option) io.Writer { logLevel := log.StdoutLevel if headless { logLevel = log.InfoLevel outWriter = errWriter } opts := make([]logwriter.Option, 0, defaultWriterOptionsLen+len(writerOptions)) opts = append(opts, logwriter.WithLogger(l.WithOptions(log.WithOutput(outWriter))), logwriter.WithDefaultLevel(logLevel), ) opts = append(opts, writerOptions...) return logwriter.New(opts...) } // buildErrWriter returns the writer for the command's stderr. // // When Terragrunt is running in Headless mode, we want to forward // any stderr to the ERROR log level, otherwise, we want to forward // stderr to the STDERR log level. // // Also accepts any additional writer options desired. func buildErrWriter(l log.Logger, headless bool, errWriter io.Writer, writerOptions ...logwriter.Option) io.Writer { logLevel := log.StderrLevel if headless { logLevel = log.ErrorLevel } opts := make([]logwriter.Option, 0, defaultWriterOptionsLen+len(writerOptions)) opts = append(opts, logwriter.WithLogger(l.WithOptions(log.WithOutput(errWriter))), logwriter.WithDefaultLevel(logLevel), ) opts = append(opts, writerOptions...) return logwriter.New(opts...) } ================================================ FILE: internal/tf/run_cmd_test.go ================================================ //go:build linux || darwin // +build linux darwin package tf_test import ( "bytes" "fmt" "path/filepath" "strings" "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( FullOutput = []string{"stdout1", "stderr1", "stdout2", "stderr2", "stderr3"} Stdout = []string{"stdout1", "stdout2"} Stderr = []string{"stderr1", "stderr2", "stderr3"} ) func TestCommandOutputPrefix(t *testing.T) { t.Parallel() prefix := "." terraformPath := "testdata/test_outputs.sh" prefixedOutput := make([]string, 0, len(FullOutput)) for _, line := range FullOutput { prefixedOutput = append(prefixedOutput, fmt.Sprintf("prefix=%s tf-path=%s msg=%s", prefix, filepath.Base(terraformPath), line)) } logFormatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) testCommandOutput(t, func(terragruntOptions *options.TerragruntOptions) { terragruntOptions.TFPath = terraformPath }, func(l log.Logger) log.Logger { l.SetOptions(log.WithFormatter(logFormatter)) return l.WithField(placeholders.WorkDirKeyName, prefix) }, assertOutputs(t, prefixedOutput, Stdout, Stderr, )) } func testCommandOutput(t *testing.T, withOptions func(*options.TerragruntOptions), withLogger func(log.Logger) log.Logger, assertResults func(string, *util.CmdOutput)) { t.Helper() terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) // Specify a single (locking) buffer for both as a way to check that the output is being written in the correct // order var allOutputBuffer BufferWithLocking terragruntOptions.Writers.Writer = &allOutputBuffer terragruntOptions.Writers.ErrWriter = &allOutputBuffer terragruntOptions.TerraformCliArgs.AppendArgument("same") terragruntOptions.TFPath = "testdata/test_outputs.sh" withOptions(terragruntOptions) l := logger.CreateLogger() l = withLogger(l) out, err := tf.RunCommandWithOutput(t.Context(), l, configbridge.TFRunOptsFromOpts(terragruntOptions), "same") assert.NotNil(t, out, "Should get output") require.NoError(t, err, "Should have no error") assert.NotNil(t, out, "Should get output") assertResults(allOutputBuffer.String(), out) } func assertOutputs( t *testing.T, expectedAllOutputs []string, expectedStdOutputs []string, expectedStdErrs []string, ) func(string, *util.CmdOutput) { t.Helper() return func(allOutput string, out *util.CmdOutput) { allOutputs := strings.Split(strings.TrimSpace(allOutput), "\n") assert.Len(t, allOutputs, len(expectedAllOutputs)) for i := range allOutputs { assert.Contains(t, allOutputs[i], expectedAllOutputs[i], allOutputs[i]) } stdOutputs := strings.Split(strings.TrimSpace(out.Stdout.String()), "\n") assert.Equal(t, expectedStdOutputs, stdOutputs) stdErrs := strings.Split(strings.TrimSpace(out.Stderr.String()), "\n") assert.Equal(t, expectedStdErrs, stdErrs) } } // A goroutine-safe bytes.Buffer type BufferWithLocking struct { buffer bytes.Buffer mutex sync.Mutex } // Write appends the contents of p to the buffer, growing the buffer as needed. It returns // the number of bytes written. func (s *BufferWithLocking) Write(p []byte) (n int, err error) { s.mutex.Lock() defer s.mutex.Unlock() return s.buffer.Write(p) } // String returns the contents of the unread portion of the buffer // as a string. If the Buffer is a nil pointer, it returns "". func (s *BufferWithLocking) String() string { s.mutex.Lock() defer s.mutex.Unlock() return s.buffer.String() } ================================================ FILE: internal/tf/source.go ================================================ package tf import ( "crypto/sha256" "encoding/hex" "fmt" "io/fs" "net/url" "os" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/go-getter" urlhelper "github.com/hashicorp/go-getter/helper/url" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) var ( forcedRegexp = regexp.MustCompile(`^([A-Za-z0-9]+)::(.+)$`) httpSchemeRegexp = regexp.MustCompile(`(?i)^https?://`) ) const matchCount = 2 // Source represents information about Terraform source code that needs to be downloaded. type Source struct { // A canonical version of RawSource, in URL format CanonicalSourceURL *url.URL // The folder where we should download the source to DownloadDir string // The folder in DownloadDir that should be used as the working directory for Terraform WorkingDir string // The path to a file in DownloadDir that stores the version number of the code VersionFile string // WalkDirWithSymlinks controls whether to walk symlinks in the downloaded source WalkDirWithSymlinks bool } func (src Source) String() string { return fmt.Sprintf("Source{CanonicalSourceURL = %v, DownloadDir = %v, WorkingDir = %v, VersionFile = %v}", src.CanonicalSourceURL, src.DownloadDir, src.WorkingDir, src.VersionFile) } // EncodeSourceVersion encodes a version number for the given source. When calculating a version number, we take the query // string of the source URL, calculate its sha1, and base 64 encode it. For remote URLs (e.g. Git URLs), this is // based on the assumption that the scheme/host/path of the URL (e.g. git::github.com/foo/bar) identifies the module // name and the query string (e.g. ?ref=v0.0.3) identifies the version. For local file paths, there is no query string, // so the same file path (/foo/bar) is always considered the same version. To detect changes the file path will be hashed // and returned as version. In case of hash error the default encoded source version will be returned. // See also the encodeSourceName and ProcessTerraformSource methods. func (src Source) EncodeSourceVersion(l log.Logger) (string, error) { if IsLocalSource(src.CanonicalSourceURL) { sourceHash := sha256.New() sourceDir := filepath.Clean(src.CanonicalSourceURL.Path) var err error walkDir := filepath.WalkDir if src.WalkDirWithSymlinks { walkDir = util.WalkDirWithSymlinks } err = walkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { if err != nil { // If we've encountered an error while walking the tree, give up return err } if d.IsDir() { // We don't use any info from directories to calculate our hash return util.SkipDirIfIgnorable(d.Name()) } // avoid checking .terraform.lock.hcl file since contents is auto-generated if d.Name() == util.TerraformLockFile { return nil } info, err := d.Info() if err != nil { return err } fileModified := info.ModTime().UnixMicro() hashContents := fmt.Sprintf("%s:%d", path, fileModified) sourceHash.Write([]byte(hashContents)) return nil }) if err == nil { hash := hex.EncodeToString(sourceHash.Sum(nil)) return hash, nil } l.WithError(err).Warnf("Could not encode version for local source") return "", err } return util.EncodeBase64Sha1(src.CanonicalSourceURL.Query().Encode()), nil } // WriteVersionFile writes a file into the DownloadDir that contains // the version number of this source code. The version number is // calculated using the EncodeSourceVersion method. func (src Source) WriteVersionFile(l log.Logger) error { version, err := src.EncodeSourceVersion(l) if err != nil { // If we failed to calculate a SHA of the downloaded source, write a SHA of // some random data into the version file. // // This ensures we attempt to redownload the source next time. version, err = util.GenerateRandomSha256() if err != nil { return errors.New(err) } } const ownerReadWriteGroupReadPerms = 0640 return errors.New(os.WriteFile(src.VersionFile, []byte(version), ownerReadWriteGroupReadPerms)) } // NewSource takes the given source path and create a Source struct from it, including the folder where the source should // be downloaded to. Our goal is to reuse the download folder for the same source URL between Terragrunt runs. // Otherwise, for every Terragrunt command, you'd have to wait for Terragrunt to download your Terraform code, download // that code's dependencies (terraform get), and configure remote state (terraform remote config), which is very slow. // // To maximize reuse, given a working directory w and a source URL s, we download code from S into the folder /T/W/H // where: // // 1. S is the part of s before the double-slash (//). This typically represents the root of the repo (e.g. // github.com/foo/infrastructure-modules). We download the entire repo so that relative paths to other files in that // repo resolve correctly. If no double-slash is specified, all of s is used. // 1. T is the OS temp dir (e.g. /tmp). // 2. W is the base 64 encoded sha1 hash of w. This ensures that if you are running Terragrunt concurrently in // multiple folders (e.g. during automated tests), then even if those folders are using the same source URL s, they // do not overwrite each other. // 3. H is the base 64 encoded sha1 of S without its query string. For remote source URLs (e.g. Git // URLs), this is based on the assumption that the scheme/host/path of the URL (e.g. git::github.com/foo/bar) // identifies the repo, and we always want to download the same repo into the same folder (see the encodeSourceName // method). We also assume the version of the module is stored in the query string (e.g. ref=v0.0.3), so we store // the base 64 encoded sha1 of the query string in a file called .terragrunt-source-version within /T/W/H. // // The downloadTerraformSourceIfNecessary decides when we should download the Terraform code and when not to. It uses // the following rules: // // 1. Always download source URLs pointing to local file paths. // 2. Only download source URLs pointing to remote paths if /T/W/H doesn't already exist or, if it does exist, if the // version number in /T/W/H/.terragrunt-source-version doesn't match the current version. func NewSource(l log.Logger, source string, downloadDir string, workingDir string, walkDirWithSymlinks bool) (*Source, error) { canonicalWorkingDir := filepath.Clean(workingDir) canonicalSourceURL, err := ToSourceURL(source, canonicalWorkingDir) if err != nil { return nil, err } rootSourceURL, modulePath, err := SplitSourceURL(l, canonicalSourceURL) if err != nil { return nil, err } if IsLocalSource(rootSourceURL) { // Always use canonical file paths for local source folders, rather than relative paths, to ensure // that the same local folder always maps to the same download folder, no matter how the local folder // path is specified rootSourceURL.Path = filepath.ToSlash(filepath.Clean(rootSourceURL.Path)) } rootPath, err := encodeSourceName(rootSourceURL) if err != nil { return nil, err } encodedWorkingDir := util.EncodeBase64Sha1(canonicalWorkingDir) updatedDownloadDir := filepath.Join(downloadDir, encodedWorkingDir, rootPath) updatedWorkingDir := filepath.Join(updatedDownloadDir, modulePath) versionFile := filepath.Join(updatedDownloadDir, ".terragrunt-source-version") return &Source{ CanonicalSourceURL: rootSourceURL, DownloadDir: updatedDownloadDir, WorkingDir: updatedWorkingDir, VersionFile: versionFile, WalkDirWithSymlinks: walkDirWithSymlinks, }, nil } // ToSourceURL converts the given source into a URL struct. // This method should be able to handle all source URLs that the terraform // init command can handle, parsing local file paths, Git paths, and HTTP URLs correctly. func ToSourceURL(source string, workingDir string) (*url.URL, error) { source, err := normalizeSourceURL(source, workingDir) if err != nil { return nil, err } // The go-getter library is what Terraform's init command uses to download source URLs. Use that library to // parse the URL. rawSourceURLWithGetter, err := getter.Detect(source, workingDir, getter.Detectors) if err != nil { return nil, errors.New(err) } return parseSourceURL(rawSourceURLWithGetter) } // We have to remove the http(s) scheme from the source URL to allow `getter.Detect` to add the source type, but only if the `getter` has a detector for that host. func normalizeSourceURL(source string, workingDir string) (string, error) { newSource := httpSchemeRegexp.ReplaceAllString(source, "") // We can't use `the getter.Detectors` global variable because we need to exclude from checking: // * `getter.FileDetector` is not a host detector // * `getter.S3Detector` we should not remove `https` from s3 link since this is a public link, and if we remove `https` scheme, `getter.S3Detector` adds `s3::https` which in turn requires credentials. detectors := []getter.Detector{ new(getter.GitHubDetector), new(getter.GitLabDetector), new(getter.GitDetector), new(getter.BitBucketDetector), new(getter.GCSDetector), } for _, detector := range detectors { _, ok, err := detector.Detect(newSource, workingDir) if err != nil { return source, errors.New(err) } if ok { return newSource, nil } } return source, nil } // Parse the given source URL into a URL struct. This method can handle source URLs that include go-getter's "forced // getter" prefixes, such as git::. func parseSourceURL(source string) (*url.URL, error) { forcedGetters := []string{} // Continuously strip the forced getters until there is no more. This is to handle complex URL schemes like the // git-remote-codecommit style URL. forcedGetter, rawSourceURL := getForcedGetter(source) for forcedGetter != "" { // Prepend like a stack, so that we prepend to the URL scheme in the right order. forcedGetters = append([]string{forcedGetter}, forcedGetters...) forcedGetter, rawSourceURL = getForcedGetter(rawSourceURL) } // Parse the URL without the getter prefix canonicalSourceURL, err := urlhelper.Parse(rawSourceURL) if err != nil { return nil, errors.New(err) } // Reattach the "getter" prefix as part of the scheme for _, forcedGetter := range forcedGetters { canonicalSourceURL.Scheme = fmt.Sprintf("%s::%s", forcedGetter, canonicalSourceURL.Scheme) } return canonicalSourceURL, nil } // IsLocalSource returns true if the given URL refers to a path on the local file system func IsLocalSource(sourceURL *url.URL) bool { return sourceURL.Scheme == "file" } // SplitSourceURL splits a source URL into the root repo and the path. The root repo is the part of the URL before the double-slash // (//), which typically represents the root of a modules repo (e.g. github.com/foo/infrastructure-modules) and the // path is everything after the double slash. If there is no double-slash in the URL, the root repo is the entire // sourceUrl and the path is an empty string. func SplitSourceURL(l log.Logger, sourceURL *url.URL) (*url.URL, string, error) { pathSplitOnDoubleSlash := strings.SplitN(sourceURL.Path, "//", 2) //nolint:mnd if len(pathSplitOnDoubleSlash) > 1 { sourceURLModifiedPath, err := parseSourceURL(sourceURL.String()) if err != nil { return nil, "", errors.New(err) } sourceURLModifiedPath.Path = pathSplitOnDoubleSlash[0] return sourceURLModifiedPath, pathSplitOnDoubleSlash[1], nil } // check if path is remote URL if sourceURL.Scheme != "" { return sourceURL, "", nil } // check if sourceUrl.Path is a local file path _, err := os.Stat(sourceURL.Path) if err != nil { // log warning message to notify user that sourceUrl.Path may not work l.Warnf("No double-slash (//) found in source URL %s. Relative paths in downloaded Terraform code may not work.", sourceURL.Path) } return sourceURL, "", nil } // Encode a the module name for the given source URL. When calculating a module name, we calculate the base 64 encoded // sha1 of the entire source URL without the query string. For remote URLs (e.g. Git URLs), this is based on the // assumption that the scheme/host/path of the URL (e.g. git::github.com/foo/bar) identifies the module name and the // query string (e.g. ?ref=v0.0.3) identifies the version. For local file paths, there is no query string, so the same // file path (/foo/bar) is always considered the same version. See also the EncodeSourceVersion and // ProcessTerraformSource methods. func encodeSourceName(sourceURL *url.URL) (string, error) { sourceURLNoQuery, err := parseSourceURL(sourceURL.String()) if err != nil { return "", errors.New(err) } sourceURLNoQuery.RawQuery = "" return util.EncodeBase64Sha1(sourceURLNoQuery.String()), nil } // Terraform source URLs can contain a "getter" prefix that specifies the type of protocol to use to download that URL, // such as "git::", which means Git should be used to download the URL. This method returns the getter prefix and the // rest of the URL. This code is copied from the getForcedGetter method of go-getter/get.go, as that method is not // exported publicly. func getForcedGetter(sourceURL string) (string, string) { if matches := forcedRegexp.FindStringSubmatch(sourceURL); len(matches) > matchCount { return matches[1], matches[2] } return "", sourceURL } ================================================ FILE: internal/tf/source_test.go ================================================ package tf_test import ( "fmt" "net/url" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func TestSplitSourceUrl(t *testing.T) { t.Parallel() testCases := []struct { name string sourceURL string expectedSo string expectedModulePath string }{ {"root-path-only-no-double-slash", "/foo", "/foo", ""}, {"parent-path-one-child-no-double-slash", "/foo/bar", "/foo/bar", ""}, {"parent-path-multiple-children-no-double-slash", "/foo/bar/baz/blah", "/foo/bar/baz/blah", ""}, {"relative-path-no-children-no-double-slash", "../foo", "../foo", ""}, {"relative-path-one-child-no-double-slash", "../foo/bar", "../foo/bar", ""}, {"relative-path-multiple-children-no-double-slash", "../foo/bar/baz/blah", "../foo/bar/baz/blah", ""}, {"root-path-only-with-double-slash", "/foo//", "/foo", ""}, {"parent-path-one-child-with-double-slash", "/foo//bar", "/foo", "bar"}, {"parent-path-multiple-children-with-double-slash", "/foo/bar//baz/blah", "/foo/bar", "baz/blah"}, {"relative-path-no-children-with-double-slash", "..//foo", "..", "foo"}, {"relative-path-one-child-with-double-slash", "../foo//bar", "../foo", "bar"}, {"relative-path-multiple-children-with-double-slash", "../foo/bar//baz/blah", "../foo/bar", "baz/blah"}, {"parent-url-one-child-no-double-slash", "ssh://git@github.com/foo/modules.git/foo", "ssh://git@github.com/foo/modules.git/foo", ""}, {"parent-url-multiple-children-no-double-slash", "ssh://git@github.com/foo/modules.git/foo/bar/baz/blah", "ssh://git@github.com/foo/modules.git/foo/bar/baz/blah", ""}, {"parent-url-one-child-with-double-slash", "ssh://git@github.com/foo/modules.git//foo", "ssh://git@github.com/foo/modules.git", "foo"}, {"parent-url-multiple-children-with-double-slash", "ssh://git@github.com/foo/modules.git//foo/bar/baz/blah", "ssh://git@github.com/foo/modules.git", "foo/bar/baz/blah"}, {"separate-ref-with-slash", "ssh://git@github.com/foo/modules.git//foo?ref=feature/modules", "ssh://git@github.com/foo/modules.git?ref=feature/modules", "foo"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() sourceURL, err := url.Parse(tc.sourceURL) require.NoError(t, err) l := logger.CreateLogger() actualRootRepo, actualModulePath, err := tf.SplitSourceURL(l, sourceURL) require.NoError(t, err) assert.Equal(t, tc.expectedSo, actualRootRepo.String()) assert.Equal(t, tc.expectedModulePath, actualModulePath) }) } } func TestToSourceUrl(t *testing.T) { t.Parallel() testCases := []struct { sourceURL string expectedSourceURL string }{ {"https://github.com/gruntwork-io/repo-name", "git::https://github.com/gruntwork-io/repo-name.git"}, {"git::https://github.com/gruntwork-io/repo-name", "git::https://github.com/gruntwork-io/repo-name"}, {"https://github.com/gruntwork-io/repo-name//modules/module-name", "git::https://github.com/gruntwork-io/repo-name.git//modules/module-name"}, {"ssh://github.com/gruntwork-io/repo-name//modules/module-name", "ssh://github.com/gruntwork-io/repo-name//modules/module-name"}, {"https://gitlab.com/catamphetamine/libphonenumber-js", "git::https://gitlab.com/catamphetamine/libphonenumber-js.git"}, {"https://bitbucket.org/atlassian/aws-ecr-push-image", "git::https://bitbucket.org/atlassian/aws-ecr-push-image.git"}, {"http://bitbucket.org/atlassian/aws-ecr-push-image", "git::https://bitbucket.org/atlassian/aws-ecr-push-image.git"}, {"https://s3-eu-west-1.amazonaws.com/modules/vpc.zip", "https://s3-eu-west-1.amazonaws.com/modules/vpc.zip"}, {"https://www.googleapis.com/storage/v1/modules/foomodule.zip", "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"}, {"https://www.googleapis.com/storage/v1/modules/foomodule.zip", "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"}, {"git::https://name@dev.azure.com/name/project-name/_git/repo-name", "git::https://name@dev.azure.com/name/project-name/_git/repo-name"}, {"https://repository.rnd.net/artifactory/generic-production-iac/tf-auto-azr-iam.2.6.0.zip", "https://repository.rnd.net/artifactory/generic-production-iac/tf-auto-azr-iam.2.6.0.zip"}, } for i, tc := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() actualSourceURL, err := tf.ToSourceURL(tc.sourceURL, os.TempDir()) require.NoError(t, err) assert.Equal(t, tc.expectedSourceURL, actualSourceURL.String()) }) } } func TestRegressionSupportForGitRemoteCodecommit(t *testing.T) { t.Parallel() source := "git::codecommit::ap-northeast-1://my_app_modules//my-app/modules/main-module" sourceURL, err := tf.ToSourceURL(source, ".") require.NoError(t, err) require.Equal(t, "git::codecommit::ap-northeast-1", sourceURL.Scheme) l := logger.CreateLogger() actualRootRepo, actualModulePath, err := tf.SplitSourceURL(l, sourceURL) require.NoError(t, err) require.Equal(t, "git::codecommit::ap-northeast-1://my_app_modules", actualRootRepo.String()) require.Equal(t, "my-app/modules/main-module", actualModulePath) } ================================================ FILE: internal/tf/testdata/test_outputs.sh ================================================ #!/usr/bin/env bash echo 'stdout1' sleep 1 >&2 echo 'stderr1' sleep 1 echo 'stdout2' sleep 1 >&2 echo 'stderr2' sleep 1 >&2 echo 'stderr3' ================================================ FILE: internal/tf/tf.go ================================================ package tf import ( "os" "path/filepath" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" ) const ( // TF commands. CommandNameInit = "init" CommandNameInitFromModule = "init-from-module" CommandNameImport = "import" CommandNamePlan = "plan" CommandNameApply = "apply" CommandNameDestroy = "destroy" CommandNameValidate = "validate" CommandNameOutput = "output" CommandNameProviders = "providers" CommandNameState = "state" CommandNameLock = "lock" CommandNameGet = "get" CommandNameGraph = "graph" CommandNameTaint = "taint" CommandNameUntaint = "untaint" CommandNameConsole = "console" CommandNameForceUnlock = "force-unlock" CommandNameShow = "show" CommandNameVersion = "version" CommandNameFmt = "fmt" CommandNameLogin = "login" CommandNameLogout = "logout" CommandNameMetadate = "metadata" CommandNamePull = "pull" CommandNamePush = "push" CommandNameRefresh = "refresh" CommandNameTest = "test" CommandNameWorkspace = "workspace" CommandNameQuery = "query" // Deprecated TF commands. CommandNameEnv = "env" // TF flags. FlagNameDetailedExitCode = "-detailed-exitcode" FlagNameHelpLong = "-help" FlagNameHelpShort = "-h" FlagNameVersion = "-version" FlagNameJSON = "-json" FlagNameNoColor = "-no-color" // `apply -destroy` is alias for `destroy` FlagNameDestroy = "-destroy" // `platform` is a flag used with the `providers lock` command. FlagNamePlatform = "-platform" EnvNameTFCLIConfigFile = "TF_CLI_CONFIG_FILE" EnvNameTFPluginCacheDir = "TF_PLUGIN_CACHE_DIR" EnvNameTFTokenFmt = "TF_TOKEN_%s" EnvNameTFVarFmt = "TF_VAR_%s" DefaultTFDataDir = ".terraform" TerraformLockFile = ".terraform.lock.hcl" TerraformPlanFile = "tfplan.tfplan" TerraformPlanJSONFile = "tfplan.json" ) var ( CommandNames = []string{ CommandNameApply, CommandNameConsole, CommandNameDestroy, CommandNameEnv, CommandNameFmt, CommandNameGet, CommandNameGraph, CommandNameImport, CommandNameInit, CommandNameLogin, CommandNameLogout, CommandNameMetadate, CommandNameOutput, CommandNamePlan, CommandNameProviders, CommandNamePush, CommandNameRefresh, CommandNameShow, CommandNameTaint, CommandNameTest, CommandNameVersion, CommandNameValidate, CommandNameUntaint, CommandNameWorkspace, CommandNameForceUnlock, CommandNameState, CommandNameQuery, } CommandUsages = map[string]string{ CommandNameApply: "Create or update infrastructure.", CommandNameConsole: "Try OpenTofu/Terraform expressions at an interactive command prompt.", CommandNameDestroy: "Destroy previously-created infrastructure.", CommandNameFmt: "Reformat your configuration in the standard style.", CommandNameGet: "Install or upgrade remote OpenTofu/Terraform modules.", CommandNameGraph: "Generate a Graphviz graph of the steps in an operation.", CommandNameImport: "Associate existing infrastructure with a OpenTofu/Terraform resource.", CommandNameInit: "Prepare your working directory for other commands.", CommandNameLogin: "Obtain and save credentials for a remote host.", CommandNameLogout: "Remove locally-stored credentials for a remote host.", CommandNameMetadate: "Metadata related commands.", CommandNameOutput: "Show output values from your root module.", CommandNamePlan: "Show changes required by the current configuration.", CommandNameProviders: "Show the providers required for this configuration.", CommandNameRefresh: "Update the state to match remote systems.", CommandNameShow: "Show the current state or a saved plan.", CommandNameTaint: "Mark a resource instance as not fully functional.", CommandNameTest: "Execute integration tests for OpenTofu/Terraform modules.", CommandNameVersion: "Show the current OpenTofu/Terraform version.", CommandNameValidate: "Check whether the configuration is valid.", CommandNameUntaint: "Remove the 'tainted' state from a resource instance.", CommandNameWorkspace: "Workspace management.", CommandNameForceUnlock: "Release a stuck lock on the current workspace.", CommandNameState: "Advanced state management.", } ) // ModuleVariables will return all the variables defined in the downloaded terraform modules, taking into // account all the generated sources. This function will return the required and optional variables separately. func ModuleVariables(modulePath string) ([]string, []string, error) { parser := hclparse.NewParser() files, err := os.ReadDir(modulePath) if err != nil { return nil, nil, err } hclFiles := []*hcl.File{} allDiags := hcl.Diagnostics{} for _, file := range files { if file.IsDir() { continue } parseFunc := parser.ParseHCLFile suffix := filepath.Ext(file.Name()) if suffix == ".json" { parseFunc = parser.ParseJSONFile } if !(slices.Contains([]string{".tf", ".tofu", ".json"}, suffix)) { continue } file, parseDiags := parseFunc(filepath.Join(modulePath, file.Name())) hclFiles = append(hclFiles, file) allDiags = append(allDiags, parseDiags...) } body := hcl.MergeFiles(hclFiles) varsSchema := &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "variable", LabelNames: []string{"name"}, }, }, } varsAttributesSchema := &hcl.BodySchema{ Attributes: []hcl.AttributeSchema{ { Name: "default", Required: false, }, }, } varsContent, _, contentDiags := body.PartialContent(varsSchema) allDiags = append(allDiags, contentDiags...) optional, required := []string{}, []string{} for _, b := range varsContent.Blocks { name := b.Labels[0] varBodyContent, _, attrDiags := b.Body.PartialContent(varsAttributesSchema) allDiags = append(allDiags, attrDiags...) if _, ok := varBodyContent.Attributes["default"]; ok { optional = append(optional, name) } else { required = append(required, name) } } if allDiags.HasErrors() { return nil, nil, errors.New(allDiags) } return required, optional, nil } ================================================ FILE: internal/tf/tf_test.go ================================================ package tf_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestModuleVariablesWithProviderFunctions verifies that ModuleVariables can parse // HCL files that use provider function syntax (provider::namespace::function). // This is a regression test for https://github.com/gruntwork-io/terragrunt/issues/3425 func TestModuleVariablesWithProviderFunctions(t *testing.T) { t.Parallel() dir := t.TempDir() hclContent := ` terraform { required_version = "~> 1.8" required_providers { assert = { source = "hashicorp/assert" version = "~> 0.13" } azurerm = { source = "hashicorp/azurerm" version = "~> 3.8" } } } data "azurerm_subnet" "main" { name = var.subnet.name resource_group_name = var.subnet.resource_group_name virtual_network_name = var.subnet.virtual_network_name lifecycle { postcondition { condition = var.subnet.enable_ipv6 == false || anytrue([ for prefix in self.address_prefixes: provider::assert::cidrv6(prefix) ]) error_message = "Subnet does not contain valid IPv6 CIDR. Either use a subnet that contains a valid IPv6 CIDR or disable IPv6 support." } } } variable "subnet" { type = object({ name = string virtual_network_name = string resource_group_name = string enable_ipv6 = optional(bool, false) }) } ` require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(hclContent), 0644)) required, optional, err := tf.ModuleVariables(dir) require.NoError(t, err) assert.Equal(t, []string{"subnet"}, required) assert.Empty(t, optional) } ================================================ FILE: internal/tfimpl/tfimpl.go ================================================ // Package tfimpl defines the Terraform implementation type constants. package tfimpl // Type represents which Terraform implementation is being used. type Type string const ( // Terraform indicates the HashiCorp Terraform binary. Terraform Type = "terraform" // OpenTofu indicates the OpenTofu binary. OpenTofu Type = "tofu" // Unknown indicates an unrecognized implementation. Unknown Type = "unknown" ) ================================================ FILE: internal/tflint/README.md ================================================ # tflint This package allows us to embed [tflint](https://github.com/terraform-linters/tflint) in Terragrunt, enabling it to be natively executed from the before and after hooks without having to install `tflint` separately. Since `tflint` is licensed with MPL, we are required to let you know where you can find its source code: . ================================================ FILE: internal/tflint/tflint.go ================================================ // Package tflint embeds execution of tflint, which is under an MPL license, and you can // find its source code at https://github.com/terraform-linters/tflint package tflint import ( "context" "fmt" "path/filepath" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) // TFLintOptions contains the subset of configuration needed by tflint execution. type TFLintOptions struct { ShellOptions *shell.ShellOptions Writers writer.Writers WorkingDir string RootWorkingDir string TerragruntConfigPath string MaxFoldersToCheck int } const ( // tfVarPrefix Prefix to use for terraform variables set with environment variables. tfVarPrefix = "TF_VAR_" argVarPrefix = "-var=" argVarFilePrefix = "-var-file=" tfExternalTFLint = "--terragrunt-external-tflint" ) // RunTflintWithOpts runs tflint with the given options and returns an error if there are any issues. func RunTflintWithOpts(ctx context.Context, l log.Logger, opts *TFLintOptions, cfg *runcfg.RunConfig, hook *runcfg.Hook) error { hookExecute := slices.Clone(hook.Execute) hookExecute = slices.DeleteFunc(hookExecute, func(arg string) bool { return arg == tfExternalTFLint }) // try to fetch configuration file from hook parameters configFile, err := tflintConfigFilePath(l, opts, hookExecute) if err != nil { return err } l.Debugf("Using .tflint.hcl file in %s", util.RelPathForLog(opts.RootWorkingDir, configFile, opts.Writers.LogShowAbsPaths)) variables, err := InputsToTflintVar(cfg.Inputs) if err != nil { return err } tfVariables, err := tfArgumentsToTflintVar(l, hook, &cfg.Terraform) if err != nil { return err } l.Debugf( "Initializing tflint in directory %s", util.RelPathForLog(opts.RootWorkingDir, opts.WorkingDir, opts.Writers.LogShowAbsPaths), ) tflintArgs := hookExecute[1:] configFileRel := util.RelPathForLog(opts.WorkingDir, configFile, opts.Writers.LogShowAbsPaths) chdirRel := util.RelPathForLog(opts.RootWorkingDir, opts.WorkingDir, opts.Writers.LogShowAbsPaths) // tflint init initArgs := []string{"tflint", "--init", "--config", configFileRel, "--chdir", chdirRel} l.Debugf("Running external tflint init with args %v", initArgs) _, err = shell.RunCommandWithOutput(ctx, l, opts.ShellOptions, opts.RootWorkingDir, false, false, initArgs[0], initArgs[1:]...) if err != nil { return errors.New(ErrorRunningTflint{Args: initArgs, Err: err}) } // tflint execution args := make([]string, 0, 5+len(variables)+len(tfVariables)+len(tflintArgs)) args = append(args, "tflint", "--config", configFileRel, "--chdir", chdirRel, ) args = append(args, variables...) args = append(args, tfVariables...) args = append(args, tflintArgs...) l.Debugf("Running external tflint with args %v", args) _, err = shell.RunCommandWithOutput(ctx, l, opts.ShellOptions, opts.RootWorkingDir, false, false, args[0], args[1:]...) if err != nil { return errors.New(ErrorRunningTflint{Args: args, Err: err}) } l.Info("Tflint has run successfully. No issues found.") return nil } // InputsToTflintVar converts the inputs map to a list of tflint variables. func InputsToTflintVar(inputs map[string]any) ([]string, error) { variables := make([]string, 0, len(inputs)) for key, value := range inputs { varValue, err := util.AsTerraformEnvVarJSONValue(value) if err != nil { return nil, err } newVar := fmt.Sprintf("--var=%s=%s", key, varValue) variables = append(variables, newVar) } return variables, nil } // Custom error types type ErrorRunningTflint struct { Err error Args []string } func (err ErrorRunningTflint) Error() string { if err.Err != nil { return fmt.Sprintf("Error encountered while running tflint with args: '%v': %s", err.Args, err.Err) } return fmt.Sprintf("Error encountered while running tflint with args: '%v'", err.Args) } func (err ErrorRunningTflint) Unwrap() error { return err.Err } type IssuesFound struct{} func (err IssuesFound) Error() string { return "Tflint found issues in the project. Check for the tflint logs." } type UnknownError struct { statusCode int } func (err UnknownError) Error() string { return fmt.Sprintf("Unknown status code from tflint: %d", err.statusCode) } type ConfigNotFound struct { cause string } func (err ConfigNotFound) Error() string { return "Could not find .tflint.hcl config file in the parent folders: " + err.cause } // tfArgumentsToTflintVar converts variables from the terraform config to a list of tflint variables. func tfArgumentsToTflintVar(l log.Logger, hook *runcfg.Hook, tfCfg *runcfg.TerraformConfig) ([]string, error) { var variables []string for i := range tfCfg.ExtraArgs { arg := &tfCfg.ExtraArgs[i] // use extra args which will be used on same command as hook if !slices.ContainsFunc(arg.Commands, func(cmd string) bool { return slices.Contains(hook.Commands, cmd) }) { continue } if len(arg.EnvVars) > 0 { // extract env_vars for name, value := range arg.EnvVars { if after, ok := strings.CutPrefix(name, tfVarPrefix); ok { varName := after varValue, err := util.AsTerraformEnvVarJSONValue(value) if err != nil { return nil, err } newVar := fmt.Sprintf("--var='%s=%s'", varName, varValue) variables = append(variables, newVar) } } } if len(arg.Arguments) > 0 { // extract variables and var files from arguments for _, value := range arg.Arguments { if after, ok := strings.CutPrefix(value, argVarPrefix); ok { varName := after newVar := fmt.Sprintf("--var='%s'", varName) variables = append(variables, newVar) } if after, ok := strings.CutPrefix(value, argVarFilePrefix); ok { varName := after newVar := "--var-file=" + varName variables = append(variables, newVar) } } } if len(arg.RequiredVarFiles) > 0 { // extract required variables for _, file := range arg.RequiredVarFiles { newVar := "--var-file=" + file variables = append(variables, newVar) } } if len(arg.OptionalVarFiles) > 0 { // extract optional variables for _, file := range util.RemoveDuplicatesKeepLast(arg.OptionalVarFiles) { if util.FileExists(file) { newVar := "--var-file=" + file variables = append(variables, newVar) } else { l.Debugf("Skipping tflint var-file %s as it does not exist", file) } } } } return variables, nil } // findTflintConfigInProject looks for a .tflint.hcl file in the current folder or it's parents. // When running from cache, we start searching from the original config directory to find config in the source directory. func findTflintConfigInProject(l log.Logger, opts *TFLintOptions) (string, error) { startDir := opts.WorkingDir if opts.TerragruntConfigPath != "" { startDir = filepath.Dir(opts.TerragruntConfigPath) } previousDir := startDir // To avoid getting into an accidental infinite loop (e.g. do to cyclical symlinks), set a max on the number of // parent folders we'll check for range opts.MaxFoldersToCheck { currentDir := filepath.Dir(previousDir) l.Debugf("Finding .tflint.hcl file from %s and going to %s", util.RelPathForLog(opts.RootWorkingDir, previousDir, opts.Writers.LogShowAbsPaths), util.RelPathForLog(opts.RootWorkingDir, currentDir, opts.Writers.LogShowAbsPaths)) if currentDir == previousDir { return "", errors.New(ConfigNotFound{cause: "Traversed all the day to the root"}) } fileToFind := filepath.Join(previousDir, ".tflint.hcl") if util.FileExists(fileToFind) { l.Debugf("Found .tflint.hcl in %s", util.RelPathForLog(opts.RootWorkingDir, fileToFind, opts.Writers.LogShowAbsPaths)) return fileToFind, nil } previousDir = currentDir } return "", errors.New(ConfigNotFound{ cause: fmt.Sprintf("Exceeded maximum folders to check (%d)", opts.MaxFoldersToCheck), }) } // tflintConfigFilePath returns the configuration file specified in --config argument func tflintConfigFilePath(l log.Logger, opts *TFLintOptions, arguments []string) (string, error) { for i, arg := range arguments { if arg == "--config" && len(arguments) > i+1 { return arguments[i+1], nil } } // find .tflint.hcl configuration in project files if it is not provided in arguments projectConfigFile, err := findTflintConfigInProject(l, opts) if err != nil { return "", err } return projectConfigFile, nil } ================================================ FILE: internal/tflint/tflint_test.go ================================================ package tflint_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/tflint" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestInputsToTflintVar(t *testing.T) { t.Parallel() testCases := []struct { name string inputs map[string]any expected []string }{ { "strings", map[string]any{"region": "eu-central-1", "instance_count": 3}, []string{"--var=region=eu-central-1", "--var=instance_count=3"}, }, { "strings and arrays", map[string]any{"cidr_blocks": []string{"10.0.0.0/16"}}, []string{"--var=cidr_blocks=[\"10.0.0.0/16\"]"}, }, { "boolean", map[string]any{"create_resource": true}, []string{"--var=create_resource=true"}, }, { "with white spaces", // With white spaces, the string is still validated by tflint. map[string]any{"region": " eu-central-1 "}, []string{"--var=region= eu-central-1 "}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual, err := tflint.InputsToTflintVar(tc.inputs) require.NoError(t, err) assert.ElementsMatch(t, tc.expected, actual) }) } } ================================================ FILE: internal/tips/errors.go ================================================ package tips import ( "strings" ) // InvalidTipNameError is an error that is returned when an invalid tip name is requested. type InvalidTipNameError struct { requestedName string allowedNames []string } func NewInvalidTipNameError(requestedName string, allowedNames []string) *InvalidTipNameError { return &InvalidTipNameError{ requestedName: requestedName, allowedNames: allowedNames, } } func (err InvalidTipNameError) Error() string { return "invalid tip suppression requested for `--no-tip`: '" + err.requestedName + "'; valid tip(s) for suppression: " + strings.Join(err.allowedNames, ", ") } func (err InvalidTipNameError) Is(target error) bool { _, ok := target.(*InvalidTipNameError) return ok } ================================================ FILE: internal/tips/tip.go ================================================ // Package tips provides utilities for displaying helpful tips to users during specific workflows. // Tips are informational messages that can help users troubleshoot issues or learn about features. // // Tips can be disabled globally using --no-tips or individually using --no-tip . package tips import ( "slices" "sync" "sync/atomic" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Tip represents a helpful tip displayed to users. type Tip struct { // Name is a unique identifier for the tip Name string // Message is the message to display when the tip is triggered Message string // OnceShow is a sync.Once to ensure the tip is only shown once per session OnceShow sync.Once // disabled is an atomic boolean to ensure the tip is only disabled once per session disabled atomic.Bool } // Tips is a collection of Tip pointers. type Tips []*Tip // Evaluate displays the tip if not disabled and not already shown. func (tip *Tip) Evaluate(l log.Logger) { if tip == nil || tip.isDisabled() || l == nil { return } tip.OnceShow.Do(func() { l.Info(tip.Message) }) } // Disable disables this tip from being shown. func (tip *Tip) Disable() { tip.disabled.Store(true) } func (tip *Tip) isDisabled() bool { return tip.disabled.Load() } // Names returns all tip names. func (t Tips) Names() []string { if len(t) == 0 { return []string{} } names := make([]string, 0, len(t)) for _, tip := range t { names = append(names, tip.Name) } slices.Sort(names) return names } // Find searches and returns the tip by the given `name`. func (t Tips) Find(name string) *Tip { if len(t) == 0 { return nil } for _, tip := range t { if tip.Name == name { return tip } } return nil } // DisableAll disables all tips such that they aren't shown. func (t Tips) DisableAll() { if len(t) == 0 { return } for _, tip := range t { tip.Disable() } } // DisableTip validates that the specified tip name is valid and disables this tip. func (t Tips) DisableTip(name string) error { found := t.Find(name) if found == nil { return NewInvalidTipNameError(name, t.Names()) } found.Disable() return nil } ================================================ FILE: internal/tips/tip_test.go ================================================ package tips_test import ( "bytes" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/tips" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTipEvaluate(t *testing.T) { t.Parallel() logger, output := newTestLogger() tip := &tips.Tip{ Name: "test-tip", Message: "This is a test tip message", } tip.Evaluate(logger) assert.Contains( t, strings.TrimSpace(output.String()), "This is a test tip message", ) } func TestTipEvaluateWithNilLogger(t *testing.T) { t.Parallel() tip := &tips.Tip{ Name: "test-tip", Message: "This is a test tip message", } // Should not panic tip.Evaluate(nil) } func TestTipEvaluateOnNilTip(t *testing.T) { t.Parallel() logger, _ := newTestLogger() var tip *tips.Tip // Should not panic tip.Evaluate(logger) } func TestTipDisable(t *testing.T) { t.Parallel() logger, output := newTestLogger() tip := &tips.Tip{ Name: "test-tip", Message: "This tip should not appear", } tip.Disable() tip.Evaluate(logger) assert.Empty(t, output.String()) } func TestTipOnceShowEnsuresTipShownOnlyOnce(t *testing.T) { t.Parallel() logger, output := newTestLogger() tip := &tips.Tip{ Name: "test-tip", Message: "Once message", } tip.Evaluate(logger) tip.Evaluate(logger) tip.Evaluate(logger) content := output.String() count := strings.Count(content, "Once message") assert.Equal(t, 1, count, "Tip should only be shown once per session") } func TestTipsDisableAll(t *testing.T) { t.Parallel() logger, output := newTestLogger() allTips := tips.NewTips() allTips.DisableAll() for _, tip := range allTips { tip.Evaluate(logger) } assert.Empty(t, output.String()) } func TestTipsDisableTip(t *testing.T) { t.Parallel() logger, output := newTestLogger() allTips := tips.NewTips() err := allTips.DisableTip(tips.DebuggingDocs) require.NoError(t, err) tip := allTips.Find(tips.DebuggingDocs) require.NotNil(t, tip) tip.Evaluate(logger) assert.Empty(t, output.String()) } func TestTipsDisableTipInvalidName(t *testing.T) { t.Parallel() allTips := tips.NewTips() err := allTips.DisableTip("invalid-tip-name") require.Error(t, err) var invalidErr *tips.InvalidTipNameError require.ErrorAs(t, err, &invalidErr) assert.Contains(t, err.Error(), "invalid tip suppression requested for `--no-tip`: 'invalid-tip-name'") assert.Contains(t, err.Error(), "valid tip(s) for suppression:") assert.Contains(t, err.Error(), tips.DebuggingDocs) } func TestTipsFind(t *testing.T) { t.Parallel() allTips := tips.NewTips() tip := allTips.Find(tips.DebuggingDocs) require.NotNil(t, tip) assert.Equal(t, tips.DebuggingDocs, tip.Name) } func TestTipsFindNonExistent(t *testing.T) { t.Parallel() allTips := tips.NewTips() tip := allTips.Find("non-existent") assert.Nil(t, tip) } func TestTipsNames(t *testing.T) { t.Parallel() allTips := tips.NewTips() names := allTips.Names() assert.Contains(t, names, tips.DebuggingDocs) } func TestNewTips(t *testing.T) { t.Parallel() allTips := tips.NewTips() assert.NotEmpty(t, allTips) // Verify the debugging-docs tip exists and has the expected message tip := allTips.Find(tips.DebuggingDocs) require.NotNil(t, tip) assert.Contains(t, tip.Message, "troubleshooting") } func newTestLogger() (log.Logger, *bytes.Buffer) { formatter := format.NewFormatter(placeholders.Placeholders{placeholders.Message()}) output := new(bytes.Buffer) logger := log.New(log.WithOutput(output), log.WithLevel(log.InfoLevel), log.WithFormatter(formatter)) return logger, output } ================================================ FILE: internal/tips/tips.go ================================================ package tips const ( // DebuggingDocs is the tip that points users to the debugging documentation. DebuggingDocs = "debugging-docs" ) // NewTips returns a new Tips collection with all available tips. // // Never remove any of these tips, as removing them will cause a breaking change for users // using an invocation of `--no-tip` pointing to a non-existent tip. // // e.g. `terragrunt run --no-tip=debugging-docs` // // If you want to programmatically document that a tip should no longer be // used after removing it from the codebase, just set `disabled` to `1` here for that tip. func NewTips() Tips { return Tips{ { Name: DebuggingDocs, Message: "TIP (" + DebuggingDocs + "): For help troubleshooting errors, visit https://docs.terragrunt.com/troubleshooting/debugging", }, } } ================================================ FILE: internal/util/collections.go ================================================ package util import ( "cmp" "regexp" "slices" "strings" ) func MatchesAny(regExps []string, s string) bool { for _, item := range regExps { if matched, _ := regexp.MatchString(item, s); matched { return true } } return false } // ListContainsSublist returns true if an instance of the sublist can be found in the given list func ListContainsSublist[S ~[]E, E comparable](list, sublist S) bool { // A list cannot contain an empty sublist if len(sublist) == 0 { return false } if len(sublist) > len(list) { return false } for i := 0; len(list[i:]) >= len(sublist); i++ { if slices.Equal(list[i:i+len(sublist)], sublist) { return true } } return false } // ListHasPrefix returns true if list starts with the given prefix list func ListHasPrefix[S ~[]E, E comparable](list, prefix S) bool { if len(prefix) == 0 { return false } if len(prefix) > len(list) { return false } return slices.Equal(list[:len(prefix)], prefix) } // RemoveDuplicates returns a new slice with duplicates removed. // Note: This function sorts the result, so original order is not preserved. func RemoveDuplicates[S ~[]E, E cmp.Ordered](list S) S { result := slices.Clone(list) slices.Sort(result) return slices.Compact(result) } // MergeSlices combines multiple slices and removes duplicates. // Note: This function sorts the result, so original order is not preserved. func MergeSlices[S ~[]E, E cmp.Ordered](slicesToMerge ...S) S { result := slices.Concat(slicesToMerge...) if result == nil { return S{} } slices.Sort(result) return slices.Compact(result) } // RemoveDuplicatesKeepLast returns a new slice with duplicates removed, keeping the last occurrence. // Unlike RemoveDuplicates, this preserves the relative order of elements. func RemoveDuplicatesKeepLast[S ~[]E, E comparable](list S) S { seen := make(map[E]int, len(list)) result := make(S, 0, len(list)) for _, item := range list { if idx, exists := seen[item]; exists { // Remove the previous occurrence result = slices.Delete(result, idx, idx+1) // Update indices for items that were shifted for k, v := range seen { if v > idx { seen[k] = v - 1 } } } seen[item] = len(result) result = append(result, item) } return result } // FirstNonEmpty returns the first non-empty/non-zero element from the slice, or the zero value if none found. func FirstNonEmpty[S ~[]E, E comparable](list S) E { var empty E for _, item := range list { if item != empty { return item } } return empty } // SplitUrls slices s into all substrings separated by sep and returns a slice of // the substrings between those separators. // Taking into account that the `=` sign can also be used as a git tag, e.g. `git@github.com/test.git?ref=feature` func SplitUrls(s, sep string) []string { masks := map[string]string{ "?ref=": "", } // mask for src, mask := range masks { s = strings.ReplaceAll(s, src, mask) } urls := strings.Split(s, sep) // unmask for i := range urls { for src, mask := range masks { urls[i] = strings.ReplaceAll(urls[i], mask, src) } } return urls } ================================================ FILE: internal/util/collections_test.go ================================================ package util_test import ( "slices" "strconv" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" ) func TestMatchesAny(t *testing.T) { t.Parallel() realWorldErrorMessages := []string{ "Failed to load state: RequestError: send request failed\ncaused by: Get https://.us-west-2.amazonaws.com/?prefix=env%3A%2F: dial tcp 54.231.176.160:443: i/o timeout", "aws_cloudwatch_metric_alarm.asg_high_memory_utilization: Creating metric alarm failed: ValidationError: A separate request to update this alarm is in progress. status code: 400, request id: 94309fbd-7e09-11e8-a5f8-1de9e697c6bf", "Error configuring the backend \"s3\": RequestError: send request failed\ncaused by: Post https://sts.amazonaws.com/: net/http: TLS handshake timeout", } testCases := []struct { element string list []string expected bool }{ {list: nil, element: "", expected: false}, {list: []string{}, element: "", expected: false}, {list: []string{}, element: "foo", expected: false}, {list: []string{"foo"}, element: "kafoot", expected: true}, {list: []string{"bar", "foo", ".*Failed to load backend.*TLS handshake timeout.*"}, element: "Failed to load backend: Error...:... TLS handshake timeout", expected: true}, {list: []string{"bar", "foo", ".*Failed to load backend.*TLS handshake timeout.*"}, element: "Failed to load backend: Error...:... TLxS handshake timeout", expected: false}, {list: []string{"(?s).*Failed to load state.*dial tcp.*timeout.*"}, element: realWorldErrorMessages[0], expected: true}, {list: []string{"(?s).*Creating metric alarm failed.*request to update this alarm is in progress.*"}, element: realWorldErrorMessages[1], expected: true}, {list: []string{"(?s).*Error configuring the backend.*TLS handshake timeout.*"}, element: realWorldErrorMessages[2], expected: true}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.MatchesAny(tc.list, tc.element) assert.Equal(t, tc.expected, actual, "For list %v and element %s", tc.list, tc.element) }) } } func TestListContainsElement(t *testing.T) { t.Parallel() testCases := []struct { element string list []string expected bool }{ {list: []string{}, element: "", expected: false}, {list: []string{}, element: "foo", expected: false}, {list: []string{"foo"}, element: "foo", expected: true}, {list: []string{"bar", "foo", "baz"}, element: "foo", expected: true}, {list: []string{"bar", "foo", "baz"}, element: "nope", expected: false}, {list: []string{"bar", "foo", "baz"}, element: "", expected: false}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := slices.Contains(tc.list, tc.element) assert.Equal(t, tc.expected, actual, "For list %v and element %s", tc.list, tc.element) }) } } func TestListEquals(t *testing.T) { t.Parallel() testCases := []struct { a []string b []string expected bool }{ {[]string{""}, []string{}, false}, {[]string{"foo"}, []string{"bar"}, false}, {[]string{"foo", "bar"}, []string{"bar"}, false}, {[]string{"foo"}, []string{"foo", "bar"}, false}, {[]string{"foo", "bar"}, []string{"bar", "foo"}, false}, {[]string{}, []string{}, true}, {[]string{""}, []string{""}, true}, {[]string{"foo", "bar"}, []string{"foo", "bar"}, true}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := slices.Equal(tc.a, tc.b) assert.Equal(t, tc.expected, actual, "For list %v and list %v", tc.a, tc.b) }) } } func TestListContainsSublist(t *testing.T) { t.Parallel() testCases := []struct { list []string sublist []string expected bool }{ {[]string{}, []string{}, false}, {[]string{}, []string{"foo"}, false}, {[]string{"foo"}, []string{}, false}, {[]string{"foo"}, []string{"bar"}, false}, {[]string{"foo"}, []string{"foo", "bar"}, false}, {[]string{"bar", "foo"}, []string{"foo", "bar"}, false}, {[]string{"bar", "foo", "gee"}, []string{"foo", "bar"}, false}, {[]string{"foo", "foo", "gee"}, []string{"foo", "bar"}, false}, {[]string{"zim", "gee", "foo", "foo", "foo"}, []string{"foo", "foo", "bar", "bar"}, false}, {[]string{""}, []string{""}, true}, {[]string{"foo"}, []string{"foo"}, true}, {[]string{"foo", "bar"}, []string{"foo"}, true}, {[]string{"bar", "foo"}, []string{"foo"}, true}, {[]string{"foo", "bar", "gee"}, []string{"foo", "bar"}, true}, {[]string{"zim", "foo", "bar", "gee"}, []string{"foo", "bar"}, true}, {[]string{"foo", "foo", "bar", "gee"}, []string{"foo", "bar"}, true}, {[]string{"zim", "gee", "foo", "bar"}, []string{"foo", "bar"}, true}, {[]string{"foo", "foo", "foo", "bar"}, []string{"foo", "foo"}, true}, {[]string{"bar", "foo", "foo", "foo"}, []string{"foo", "foo"}, true}, {[]string{"zim", "gee", "foo", "bar"}, []string{"gee", "foo", "bar"}, true}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.ListContainsSublist(tc.list, tc.sublist) assert.Equal(t, tc.expected, actual, "For list %v and sublist %v", tc.list, tc.sublist) }) } } func TestListHasPrefix(t *testing.T) { t.Parallel() testCases := []struct { list []string prefix []string expected bool }{ {[]string{}, []string{}, false}, {[]string{""}, []string{}, false}, {[]string{"foo"}, []string{"bar"}, false}, {[]string{"foo", "bar"}, []string{"bar"}, false}, {[]string{"foo"}, []string{"foo", "bar"}, false}, {[]string{"foo", "bar", "foo"}, []string{"bar", "foo"}, false}, {[]string{""}, []string{""}, true}, {[]string{"", "foo"}, []string{""}, true}, {[]string{"foo", "bar"}, []string{"foo"}, true}, {[]string{"foo", "bar"}, []string{"foo", "bar"}, true}, {[]string{"foo", "bar", "biz"}, []string{"foo", "bar"}, true}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.ListHasPrefix(tc.list, tc.prefix) assert.Equal(t, tc.expected, actual, "For list %v and prefix %v", tc.list, tc.prefix) }) } } func TestRemoveDuplicates(t *testing.T) { t.Parallel() testCases := []struct { list []string expected []string }{ {[]string{}, []string{}}, {[]string{"foo"}, []string{"foo"}}, {[]string{"foo", "bar"}, []string{"bar", "foo"}}, // sorted {[]string{"foo", "bar", "foobar", "bar", "foo"}, []string{"bar", "foo", "foobar"}}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.RemoveDuplicates(tc.list) assert.Equal(t, tc.expected, actual, "For list %v", tc.list) }) } } func TestRemoveDuplicatesKeepLast(t *testing.T) { t.Parallel() testCases := []struct { list []string expected []string }{ {[]string{}, []string{}}, {[]string{"foo"}, []string{"foo"}}, {[]string{"foo", "bar"}, []string{"foo", "bar"}}, {[]string{"foo", "bar", "foobar", "bar", "foo"}, []string{"foobar", "bar", "foo"}}, {[]string{"foo", "bar", "foobar", "foo", "bar"}, []string{"foobar", "foo", "bar"}}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.RemoveDuplicatesKeepLast(tc.list) assert.Equal(t, tc.expected, actual, "For list %v", tc.list) }) } } func TestMergeSlices(t *testing.T) { t.Parallel() testCases := []struct { a []string b []string expected []string }{ {[]string{}, []string{}, []string{}}, {[]string{"foo"}, []string{}, []string{"foo"}}, {[]string{}, []string{"bar"}, []string{"bar"}}, {[]string{"foo"}, []string{"bar"}, []string{"bar", "foo"}}, // sorted {[]string{"foo", "bar"}, []string{"bar", "baz"}, []string{"bar", "baz", "foo"}}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.MergeSlices(tc.a, tc.b) assert.Equal(t, tc.expected, actual, "For lists %v and %v", tc.a, tc.b) }) } } ================================================ FILE: internal/util/datetime.go ================================================ //nolint:gocritic package util import ( "errors" "fmt" "time" ) func ParseTimestamp(ts string) (time.Time, error) { t, err := time.Parse(time.RFC3339, ts) if err != nil { // TODO: Remove this lint suppression switch err := err.(type) { //nolint:errorlint case *time.ParseError: // If err is a time.ParseError then its string representation is not // appropriate since it relies on details of Go's strange date format // representation, which a caller of our functions is not expected // to be familiar with. // // Therefore we do some light transformation to get a more suitable // error that should make more sense to our callers. These are // still not awesome error messages, but at least they refer to // the timestamp portions by name rather than by Go's example // values. if err.LayoutElem == "" && err.ValueElem == "" && err.Message != "" { // For some reason err.Message is populated with a ": " prefix // by the time package. return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp%s", err.Message) } var what string switch err.LayoutElem { case "2006": what = "year" case "01": what = "month" case "02": what = "day of month" case "15": what = "hour" case "04": what = "minute" case "05": what = "second" case "Z07:00": what = "UTC offset" case "T": return time.Time{}, errors.New("not a valid RFC3339 timestamp: missing required time introducer 'T'") case ":", "-": if err.ValueElem == "" { return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string where %q is expected", err.LayoutElem) } else { return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: found %q where %q is expected", err.ValueElem, err.LayoutElem) } default: // Should never get here, because time.RFC3339 includes only the // above portions, but since that might change in future we'll // be robust here. what = "timestamp segment" } if err.ValueElem == "" { return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string before %s", what) } else { return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: cannot use %q as %s", err.ValueElem, what) } } return time.Time{}, err } return t, nil } ================================================ FILE: internal/util/datetime_test.go ================================================ package util_test import ( "fmt" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseTimestamp(t *testing.T) { t.Parallel() testCases := []struct { arg string value time.Time err string }{ {"2017-11-22T00:00:00Z", time.Date(2017, time.Month(11), 22, 0, 0, 0, 0, time.UTC), ""}, {"2017-11-22T01:00:00+01:00", time.Date(2017, time.Month(11), 22, 1, 0, 0, 0, time.FixedZone("", 3600)), ""}, {"bloop", time.Time{}, `not a valid RFC3339 timestamp: cannot use "bloop" as year`}, {"2017-11-22 00:00:00Z", time.Time{}, `not a valid RFC3339 timestamp: missing required time introducer 'T'`}, } for _, tc := range testCases { t.Run(fmt.Sprintf("ParseTimestamp(%#v)", tc.arg), func(t *testing.T) { t.Parallel() actual, err := util.ParseTimestamp(tc.arg) if tc.err != "" { require.EqualError(t, err, tc.err) } else { require.NoError(t, err) } assert.Equal(t, tc.value, actual) }) } } ================================================ FILE: internal/util/dirs.go ================================================ package util import ( "path/filepath" ) // DefaultWorkingAndDownloadDirs gets the default working and download // directories for the given Terragrunt config path. func DefaultWorkingAndDownloadDirs(terragruntConfigPath string) (string, string) { workingDir := filepath.Dir(terragruntConfigPath) downloadDir := filepath.Clean(filepath.Join(workingDir, TerragruntCacheDir)) return workingDir, downloadDir } ================================================ FILE: internal/util/file.go ================================================ package util import ( "bytes" "context" "crypto/sha256" "encoding/gob" "fmt" "io" "io/fs" "net/url" "os" "path/filepath" "regexp" "strings" "syscall" urlhelper "github.com/hashicorp/go-getter/helper/url" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mattn/go-zglob" "github.com/mitchellh/go-homedir" ) const ( TerraformLockFile = ".terraform.lock.hcl" TerragruntCacheDir = ".terragrunt-cache" TerraformCacheDir = ".terraform" GitDir = ".git" DefaultBoilerplateDir = ".boilerplate" ChecksumReadBlock = 8192 ) // FileOrData will read the contents of the data of the given arg if it is a file, and otherwise return the contents by // itself. This will return an error if the given path is a directory. func FileOrData(maybePath string) (string, error) { // We can blindly pass in maybePath to homedir.Expand, because homedir.Expand only does something if the first // character is ~, and if it is, there is a high chance of it being a path instead of data contents. expandedMaybePath, err := homedir.Expand(maybePath) if err != nil { return "", errors.New(err) } if IsFile(expandedMaybePath) { contents, err := os.ReadFile(expandedMaybePath) if err != nil { return "", errors.New(err) } return string(contents), nil } else if IsDir(expandedMaybePath) { return "", errors.New(PathIsNotFile{path: expandedMaybePath}) } return expandedMaybePath, nil } // FileExists returns true if the given file exists. func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } // FileNotExists returns true if the given file does not exist. func FileNotExists(path string) bool { _, err := os.Stat(path) return os.IsNotExist(err) } // EnsureDirectory creates a directory at this path if it does not exist, or error if the path exists and is a file. func EnsureDirectory(path string) error { if FileExists(path) && IsFile(path) { return errors.New(PathIsNotDirectory{path}) } else if !FileExists(path) { const ownerReadWriteExecutePerms = 0700 return errors.New(os.MkdirAll(path, ownerReadWriteExecutePerms)) } return nil } // CanonicalPath returns the canonical version of the given path, relative to the given base path. That is, if the given // path is a relative path, assume it is relative to the given base path. A canonical path is an absolute path with all // relative components (e.g. "../") fully resolved, which makes it safe to compare paths as strings. If the path is // relative, basePath must be absolute or an error is returned. func CanonicalPath(path string, basePath string) (string, error) { if !filepath.IsAbs(path) { if !filepath.IsAbs(basePath) { return "", fmt.Errorf("base path %q is not absolute", basePath) } path = filepath.Join(basePath, path) } return filepath.Clean(path), nil } // Grep returns true if the given regex can be found in any of the files matched by the given glob. func Grep(regex *regexp.Regexp, glob string) (bool, error) { // Ideally, we'd use a builin Go library like filepath.Glob here, but per https://github.com/golang/go/issues/11862, // the current go implementation doesn't support treating ** as zero or more directories, just zero or one. // So we use a third-party library. matches, err := zglob.Glob(glob) if err != nil { return false, errors.New(err) } for _, match := range matches { if IsDir(match) { continue } bytes, err := os.ReadFile(match) if err != nil { return false, errors.New(err) } if regex.Match(bytes) { return true, nil } } return false, nil } // FindTFFiles walks through the directory and returns all OpenTofu/Terraform files (.tf, .tofu, .tf.json, .tofu.json) func FindTFFiles(rootPath string) ([]string, error) { var terraformFiles []string err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if IsTFFile(path) { terraformFiles = append(terraformFiles, path) } return nil }) return terraformFiles, err } // RegexFoundInTFFiles walks through the directory and checks if any OpenTofu/Terraform files (.tf, .tofu, .tf.json, .tofu.json) contain the given regex pattern func RegexFoundInTFFiles(workingDir string, pattern *regexp.Regexp) (bool, error) { var found bool err := filepath.WalkDir(workingDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if !IsTFFile(path) { return nil } content, err := os.ReadFile(path) if err != nil { return err } if pattern.Match(content) { found = true return filepath.SkipAll } return nil }) return found, err } // DirContainsTFFiles checks if the given directory contains any Terraform/OpenTofu files (.tf, .tofu, .tf.json, .tofu.json) func DirContainsTFFiles(dirPath string) (bool, error) { var found bool err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if IsTFFile(path) { found = true return filepath.SkipAll } return nil }) return found, err } // IsTFFile checks if a given file is a Terraform/OpenTofu file (.tf, .tofu, .tf.json, .tofu.json) func IsTFFile(path string) bool { suffixes := []string{ ".tf", ".tofu", ".tf.json", ".tofu.json", } for _, suffix := range suffixes { if strings.HasSuffix(path, suffix) { return true } } return false } // IsDir returns true if the path points to a directory. func IsDir(path string) bool { fileInfo, err := os.Stat(path) return err == nil && fileInfo.IsDir() } // IsFile returns true if the path points to a file. func IsFile(path string) bool { fileInfo, err := os.Stat(path) return err == nil && !fileInfo.IsDir() } // GetPathRelativeTo returns the relative path you would have to take to get from basePath to path. func GetPathRelativeTo(path string, basePath string) (string, error) { if path == "" { path = "." } if basePath == "" { basePath = "." } relPath, err := filepath.Rel(basePath, path) if err != nil { return "", errors.New(err) } return relPath, nil } // ReadFileAsString returns the contents of the file at the given path as a string. func ReadFileAsString(path string) (string, error) { bytes, err := os.ReadFile(path) if err != nil { return "", errors.Errorf("error reading file at path %s: %w", path, err) } return string(bytes), nil } func listContainsElementWithPrefix(list []string, elementPrefix string) bool { for _, element := range list { if strings.HasPrefix(element, elementPrefix) { return true } } return false } func pathContainsPrefix(path string, prefixes []string) bool { for _, element := range prefixes { if strings.HasPrefix(path, element) { return true } } return false } // Takes apbsolute glob path and returns an array of expanded relative paths func expandGlobPath(source, absoluteGlobPath string) ([]string, error) { includeExpandedGlobs := []string{} absoluteExpandGlob, err := zglob.Glob(absoluteGlobPath) if err != nil && !errors.Is(err, os.ErrNotExist) { // we ignore not exist error as we only care about the globs that exist in the src dir return nil, errors.New(err) } for _, absoluteExpandGlobPath := range absoluteExpandGlob { if strings.Contains(absoluteExpandGlobPath, TerragruntCacheDir) { continue } relativeExpandGlobPath, err := GetPathRelativeTo(absoluteExpandGlobPath, source) if err != nil { return nil, err } includeExpandedGlobs = append(includeExpandedGlobs, filepath.ToSlash(relativeExpandGlobPath)) if IsDir(absoluteExpandGlobPath) { dirExpandGlob, err := expandGlobPath(source, absoluteExpandGlobPath+"/*") if err != nil { return nil, errors.New(err) } includeExpandedGlobs = append(includeExpandedGlobs, dirExpandGlob...) } } return includeExpandedGlobs, nil } // CopyFolderContents copies the files and folders within the source folder into the destination folder. Note that hidden files and folders // (those starting with a dot) will be skipped. Will create a specified manifest file that contains paths of all copied files. func CopyFolderContents( l log.Logger, source, destination, manifestFile string, includeInCopy []string, excludeFromCopy []string, ) error { // We use filepath.ToSlash because we end up using globs here, and those expect forward slashes. source = filepath.ToSlash(source) destination = filepath.ToSlash(destination) // Expand all the includeInCopy glob paths, converting the globbed results to relative paths so that they work in // the copy filter. includeExpandedGlobs := []string{} for _, includeGlob := range includeInCopy { globPath := filepath.Join(source, includeGlob) expandGlob, err := expandGlobPath(source, globPath) if err != nil { return errors.New(err) } includeExpandedGlobs = append(includeExpandedGlobs, expandGlob...) } excludeExpandedGlobs := []string{} for _, excludeGlob := range excludeFromCopy { globPath := filepath.Join(source, excludeGlob) expandGlob, err := expandGlobPath(source, globPath) if err != nil { return errors.New(err) } excludeExpandedGlobs = append(excludeExpandedGlobs, expandGlob...) } return CopyFolderContentsWithFilter(l, source, destination, manifestFile, func(absolutePath string) bool { relativePath, err := GetPathRelativeTo(absolutePath, source) if err != nil { return false } relativePath = filepath.ToSlash(relativePath) pathHasPrefix := pathContainsPrefix(relativePath, excludeExpandedGlobs) listHasElementWithPrefix := listContainsElementWithPrefix(includeExpandedGlobs, relativePath) if listHasElementWithPrefix && !pathHasPrefix { return true } if pathHasPrefix { return false } return !TerragruntExcludes(filepath.FromSlash(relativePath)) }) } // CopyFolderContentsWithFilter copies the files and folders within the source folder into the destination folder. func CopyFolderContentsWithFilter(l log.Logger, source, destination, manifestFile string, filter func(absolutePath string) bool) error { const ownerReadWriteExecutePerms = 0700 if err := os.MkdirAll(destination, ownerReadWriteExecutePerms); err != nil { return errors.New(err) } manifest := NewFileManifest(destination, manifestFile) if err := manifest.Clean(l); err != nil { return errors.New(err) } if err := manifest.Create(); err != nil { return errors.New(err) } defer func(manifest *fileManifest) { err := manifest.Close() if err != nil { l.Warnf("Error closing manifest file: %v", err) } }(manifest) // Why use filepath.Glob here? The original implementation used os.ReadDir, but that method calls lstat on all // the files/folders in the directory, including files/folders you may want to explicitly skip. The next attempt // was to use filepath.Walk, but that doesn't work because it ignores symlinks. So, now we turn to filepath.Glob. files, err := filepath.Glob(source + "/*") if err != nil { return errors.New(err) } for _, file := range files { fileRelativePath, err := GetPathRelativeTo(file, source) if err != nil { return err } if !filter(file) { continue } dest := filepath.Join(destination, fileRelativePath) if IsDir(file) { info, err := os.Lstat(file) if err != nil { return errors.New(err) } if err := os.MkdirAll(dest, info.Mode()); err != nil { return errors.New(err) } if err := CopyFolderContentsWithFilter(l, file, dest, manifestFile, filter); err != nil { return err } if err := manifest.AddDirectory(dest); err != nil { return err } } else { parentDir := filepath.Dir(dest) const ownerReadWriteExecutePerms = 0700 if err := os.MkdirAll(parentDir, ownerReadWriteExecutePerms); err != nil { return errors.New(err) } if err := CopyFile(file, dest); err != nil { return err } if err := manifest.AddFile(dest); err != nil { return err } } } return nil } // CopyFolderToTemp creates a temp directory with the given prefix, copies the // contents of the source folder into it using the provided filter, and returns // the path to the temp directory. func CopyFolderToTemp(source string, tempPrefix string, filter func(path string) bool) (string, error) { dest, err := os.MkdirTemp("", tempPrefix) if err != nil { return "", errors.New(err) } if err := CopyFolderContentsWithFilter(log.New(), source, dest, ".copymanifest", filter); err != nil { return "", err } return dest, nil } // IsSymLink returns true if the given file is a symbolic link // Per https://stackoverflow.com/a/18062079/2308858 func IsSymLink(path string) bool { fileInfo, err := os.Lstat(path) return err == nil && fileInfo.Mode()&os.ModeSymlink != 0 } func TerragruntExcludes(path string) bool { // Do not exclude the terraform lock file (new feature added in terraform 0.14) if filepath.Base(path) == TerraformLockFile { return false } pathParts := strings.SplitSeq(path, string(filepath.Separator)) for pathPart := range pathParts { if strings.HasPrefix(pathPart, ".") && pathPart != "." && pathPart != ".." { return true } } return false } // CopyFile copies a file from source to destination. func CopyFile(source string, destination string) error { contents, err := os.ReadFile(source) if err != nil { return errors.New(err) } return WriteFileWithSamePermissions(source, destination, contents) } // WriteFileWithSamePermissions writes a file to the given destination with the given contents // using the same permissions as the file at source. func WriteFileWithSamePermissions(source string, destination string, contents []byte) error { fileInfo, err := os.Stat(source) if err != nil { return errors.New(err) } // If destination exists, remove it first to avoid permission issues // This is especially important when CAS creates read-only files if FileExists(destination) { if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { return errors.New(err) } } return os.WriteFile(destination, contents, fileInfo.Mode()) } // ContainsPath returns true if path contains the given subpath // E.g. path="foo/bar/bee", subpath="bar/bee" -> true // E.g. path="foo/bar/bee", subpath="bar/be" -> false (because be is not a directory) func ContainsPath(path, subpath string) bool { splitPath := strings.Split(filepath.Clean(path), string(filepath.Separator)) splitSubpath := strings.Split(filepath.Clean(subpath), string(filepath.Separator)) return ListContainsSublist(splitPath, splitSubpath) } // HasPathPrefix returns true if path starts with the given path prefix // E.g. path="/foo/bar/biz", prefix="/foo/bar" -> true // E.g. path="/foo/bar/biz", prefix="/foo/ba" -> false (because ba is not a directory // path) func HasPathPrefix(path, prefix string) bool { splitPath := strings.Split(filepath.Clean(path), string(filepath.Separator)) splitPrefix := strings.Split(filepath.Clean(prefix), string(filepath.Separator)) return ListHasPrefix(splitPath, splitPrefix) } // JoinTerraformModulePath joins two paths together with a double-slash between them, as this is what // Terraform uses to identify where a "repo" ends and a path within the repo begins. // Note: The Terraform docs only mention two forward-slashes, so it's not clear // if on Windows those should be two back-slashes? https://www.terraform.io/docs/modules/sources.html func JoinTerraformModulePath(modulesFolder string, path string) string { cleanModulesFolder := strings.TrimRight(modulesFolder, `/\`) cleanPath := strings.TrimLeft(path, `/\`) // if source path contains "?ref=", reconstruct module dir using "//" if strings.Contains(cleanModulesFolder, "?ref=") && cleanPath != "" { canonicalSourceURL, err := urlhelper.Parse(cleanModulesFolder) if err == nil { // append path if canonicalSourceURL.Opaque != "" { canonicalSourceURL.Opaque = fmt.Sprintf("%s//%s", strings.TrimRight(canonicalSourceURL.Opaque, `/\`), cleanPath) } else { canonicalSourceURL.Path = fmt.Sprintf("%s//%s", strings.TrimRight(canonicalSourceURL.Path, `/\`), cleanPath) } return canonicalSourceURL.String() } } // fallback to old behavior if we can't parse the url return fmt.Sprintf("%s//%s", cleanModulesFolder, cleanPath) } // fileManifest represents a manifest with paths of all files copied by terragrunt. // This allows to clean those files on subsequent runs. // The problem is as follows: terragrunt copies the terraform source code first to "working directory" using go-getter, // and then copies all files from the working directory to the above dir. // It works fine on the first run, but if we delete a file from the current terragrunt directory, we want it // to be cleaned in the "working directory" as well. Since we don't really know what can get copied by go-getter, // we have to track all the files we touch in a manifest. This way we know exactly which files we need to clean on // subsequent runs. type fileManifest struct { encoder *gob.Encoder fileHandle *os.File ManifestFolder string ManifestFile string } // fileManifestEntry represents an entry in the fileManifest. // It uses a struct with IsDir flag so that we won't have to call Stat on every // file to determine if it's a directory or a file type fileManifestEntry struct { Path string IsDir bool } // Clean will recursively remove all files specified in the manifest func (manifest *fileManifest) Clean(l log.Logger) error { return manifest.clean(l, filepath.Join(manifest.ManifestFolder, manifest.ManifestFile)) } // clean cleans the files in the manifest. If it has a directory entry, then it recursively calls clean() func (manifest *fileManifest) clean(l log.Logger, manifestPath string) error { // if manifest file doesn't exist, just exit if !FileExists(manifestPath) { return nil } file, err := os.Open(manifestPath) if err != nil { return err } // cleaning manifest file defer func(name string) { if err := file.Close(); err != nil { l.Warnf("Error closing file %s: %v", name, err) } if err := os.Remove(name); err != nil { l.Warnf("Error removing manifest file %s: %v", name, err) } }(manifestPath) decoder := gob.NewDecoder(file) // decode paths one by one for { var manifestEntry fileManifestEntry err = decoder.Decode(&manifestEntry) if err != nil { if errors.Is(err, io.EOF) { break } else { return err } } if manifestEntry.IsDir { // join the directory entry path with the manifest file name and call clean() if err := manifest.clean(l, filepath.Join(manifestEntry.Path, manifest.ManifestFile)); err != nil { return errors.New(err) } } else { if err := os.Remove(manifestEntry.Path); err != nil && !os.IsNotExist(err) { return errors.New(err) } } } return nil } // Create will create the manifest file func (manifest *fileManifest) Create() error { const ownerWriteGlobalReadPerms = 0644 fileHandle, err := os.OpenFile(filepath.Join(manifest.ManifestFolder, manifest.ManifestFile), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, ownerWriteGlobalReadPerms) if err != nil { return err } manifest.fileHandle = fileHandle manifest.encoder = gob.NewEncoder(manifest.fileHandle) return nil } // AddFile will add the file path to the manifest file. Please make sure to run Create() before using this func (manifest *fileManifest) AddFile(path string) error { return manifest.encoder.Encode(fileManifestEntry{Path: path, IsDir: false}) } // AddDirectory will add the directory path to the manifest file. Please make sure to run Create() before using this func (manifest *fileManifest) AddDirectory(path string) error { return manifest.encoder.Encode(fileManifestEntry{Path: path, IsDir: true}) } // Close closes the manifest file handle func (manifest *fileManifest) Close() error { return manifest.fileHandle.Close() } func NewFileManifest(manifestFolder string, manifestFile string) *fileManifest { return &fileManifest{ManifestFolder: manifestFolder, ManifestFile: manifestFile} } // Custom errors // PathIsNotDirectory is returned when the given path is unexpectedly not a directory. type PathIsNotDirectory struct { path string } func (err PathIsNotDirectory) Error() string { return err.path + " is not a directory" } // PathIsNotFile is returned when the given path is unexpectedly not a file. type PathIsNotFile struct { path string } func (err PathIsNotFile) Error() string { return err.path + " is not a file" } // ListTfFiles returns a list of all TF files in the specified directory. func ListTfFiles(directoryPath string, walkWithSymlinks bool) ([]string, error) { var tfFiles []string walkFunc := filepath.WalkDir if walkWithSymlinks { walkFunc = WalkDirWithSymlinks } err := walkFunc(directoryPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && IsTFFile(path) { tfFiles = append(tfFiles, path) } return nil }) return tfFiles, err } // IsDirectoryEmpty - returns true if the given path exists and is a empty directory. func IsDirectoryEmpty(dirPath string) (bool, error) { dir, err := os.Open(dirPath) if err != nil { return false, err } defer func() { _ = dir.Close() }() _, err = dir.Readdir(1) if err == nil { return false, nil } return true, nil } // GetCacheDir returns the global terragrunt cache directory for the current user. func GetCacheDir() (string, error) { cacheDir, err := os.UserCacheDir() if err != nil { return "", errors.New(err) } cacheDir = filepath.Join(cacheDir, "terragrunt") if !FileExists(cacheDir) { if err := os.MkdirAll(cacheDir, os.ModePerm); err != nil { return "", errors.New(err) } } return cacheDir, nil } // GetTempDir returns the global terragrunt temp directory. func GetTempDir() (string, error) { tempDir := filepath.Join(os.TempDir(), "terragrunt") if !FileExists(tempDir) { if err := os.MkdirAll(tempDir, os.ModePerm); err != nil { return "", errors.New(err) } } return tempDir, nil } // ExcludeFiltersFromFile returns a list of filters from the given filename, where each filter starts on a new line. // // Note that this is a backwards compatibility implementation for the `--queue-excludes-file` flag, so it's going to // append the ! prefix to each filter to negate it. func ExcludeFiltersFromFile(baseDir, filename string) ([]string, error) { filename, err := CanonicalPath(filename, baseDir) if err != nil { return nil, err } if !FileExists(filename) || !IsFile(filename) { return nil, nil } content, err := ReadFileAsString(filename) if err != nil { return nil, err } var ( lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") filters = make([]string, 0, len(lines)) ) for _, dir := range lines { dir = strings.TrimSpace(dir) if dir == "" || strings.HasPrefix(dir, "#") { continue } filters = append(filters, "!"+dir) } return filters, nil } // GetFiltersFromFile returns a list of filter queries from the given filename, where each filter query starts on a new line. func GetFiltersFromFile(baseDir, filename string) ([]string, error) { filename, err := CanonicalPath(filename, baseDir) if err != nil { return nil, err } if !FileExists(filename) || !IsFile(filename) { return nil, nil } content, err := ReadFileAsString(filename) if err != nil { return nil, err } var ( lines = strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") filters = make([]string, 0, len(lines)) ) for _, filter := range lines { filter = strings.TrimSpace(filter) if filter == "" || strings.HasPrefix(filter, "#") { continue } filters = append(filters, filter) } return filters, nil } // MatchSha256Checksum returns the SHA256 checksum for the given file and filename. func MatchSha256Checksum(file, filename []byte) []byte { var checksum []byte for line := range bytes.SplitSeq(file, []byte("\n")) { parts := bytes.Fields(line) if len(parts) > 1 && bytes.Equal(parts[1], filename) { checksum = parts[0] break } } if checksum == nil { return nil } return checksum } // FileSHA256 calculates the SHA256 hash of the file at the given path. func FileSHA256(filePath string) ([]byte, error) { file, err := os.Open(filePath) if err != nil { return nil, errors.New(err) } defer file.Close() //nolint:errcheck hash := sha256.New() buffer := make([]byte, ChecksumReadBlock) for { n, err := file.Read(buffer) if err != nil && err != io.EOF { return nil, errors.New(err) } if n == 0 { break } if _, err := hash.Write(buffer[:n]); err != nil { return nil, errors.New(err) } } return hash.Sum(nil), nil } // readerFunc is syntactic sugar for read interface. type readerFunc func(data []byte) (int, error) func (rf readerFunc) Read(data []byte) (int, error) { return rf(data) } // writerFunc is syntactic sugar for write interface. type writerFunc func(data []byte) (int, error) func (wf writerFunc) Write(data []byte) (int, error) { return wf(data) } // Copy is a io.Copy cancellable by context. func Copy(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { num, err := io.Copy( writerFunc(func(data []byte) (int, error) { select { case <-ctx.Done(): // context has been canceled stop process and propagate "context canceled" error. return 0, ctx.Err() default: // otherwise just run default io.Writer implementation. return dst.Write(data) } }), readerFunc(func(data []byte) (int, error) { select { case <-ctx.Done(): // context has been canceled stop process and propagate "context canceled" error. return 0, ctx.Err() default: // otherwise just run default io.Reader implementation. return src.Read(data) } }), ) if err != nil { err = errors.New(err) } return num, err } // evalRealPathForWalkDir evaluates symlinks and returns the real path and whether it's a directory. func evalRealPathForWalkDir(currentPath string) (string, bool, error) { realPath, err := filepath.EvalSymlinks(currentPath) if err != nil { return "", false, errors.Errorf("failed to evaluate symlinks for %s: %w", currentPath, err) } realInfo, err := os.Stat(realPath) if err != nil { return "", false, errors.Errorf("failed to describe file %s: %w", realPath, err) } return realPath, realInfo.IsDir(), nil } // WalkDirWithSymlinks traverses a directory tree using filepath.WalkDir, following symbolic links // and calling the provided function for each file or directory encountered. It handles both regular // symlinks and circular symlinks without getting into infinite loops. // //nolint:funlen func WalkDirWithSymlinks(root string, externalWalkFn fs.WalkDirFunc) error { // pathPair keeps track of both the physical (real) path on disk // and the logical path (how it appears in the walk) type pathPair struct { physical string logical string } // visited tracks symlink paths to prevent circular references // key is combination of realPath:symlinkPath visited := make(map[string]bool) // visitedLogical tracks logical paths to prevent duplicates // when the same directory is reached through different symlinks visitedLogical := make(map[string]bool) var walkFn func(pathPair) error walkFn = func(pair pathPair) error { return filepath.WalkDir(pair.physical, func(currentPath string, d fs.DirEntry, err error) error { if err != nil { return externalWalkFn(currentPath, d, err) } // Convert the current physical path to a logical path relative to the walk root rel, err := filepath.Rel(pair.physical, currentPath) if err != nil { return errors.Errorf("failed to get relative path between %s and %s: %w", pair.physical, currentPath, err) } logicalPath := filepath.Join(pair.logical, rel) // Call the provided function only if we haven't seen this logical path before if !visitedLogical[logicalPath] { visitedLogical[logicalPath] = true if err := externalWalkFn(logicalPath, d, nil); err != nil { return err } } // If we encounter a symlink, resolve and follow it if d.Type()&fs.ModeSymlink != 0 { realPath, isDir, evalErr := evalRealPathForWalkDir(currentPath) if evalErr != nil { return evalErr } // Skip if we've seen this symlink->target combination before // This prevents infinite loops with circular symlinks if visited[realPath+":"+currentPath] { return nil } visited[realPath+":"+currentPath] = true // If the target is a directory, recursively walk it if isDir { return walkFn(pathPair{ physical: realPath, logical: logicalPath, }) } } return nil }) } realRoot, err := filepath.EvalSymlinks(root) if err != nil { return errors.Errorf("failed to evaluate symlinks for %s: %w", root, err) } // Start the walk from the root directory return walkFn(pathPair{ physical: realRoot, logical: realRoot, }) } // SanitizePath resolves a file path within a base directory, returning the sanitized path or an error if it attempts // to access anything outside the base directory. func SanitizePath(baseDir string, file string) (string, error) { if baseDir == "" || file == "" { return "", errors.New("baseDir and file must be provided") } file, err := url.QueryUnescape(file) if err != nil { return "", err } baseDir, err = url.QueryUnescape(baseDir) if err != nil { return "", err } root, err := os.OpenRoot(baseDir) if err != nil { return "", err } defer root.Close() //nolint:errcheck fileInfo, err := root.Stat(file) if err != nil { return "", err } fullPath := baseDir + string(os.PathSeparator) + fileInfo.Name() return fullPath, nil } // RelPathForLog returns a relative path suitable for logging. // If the path cannot be made relative, it returns the original path. // Paths that don't start with ".." get a "./" prefix for clarity. // If showAbsPath is true, the original targetPath is returned unchanged. func RelPathForLog(basePath, targetPath string, showAbsPath bool) string { if showAbsPath { return targetPath } if relPath, err := filepath.Rel(basePath, targetPath); err == nil { if relPath == "." { return targetPath } // Add "./" prefix for paths within the base directory for clarity if !strings.HasPrefix(relPath, "..") { return "." + string(filepath.Separator) + relPath } return relPath } return targetPath } // ResolvePath resolves symlinks in a path for consistent comparison across platforms. // On macOS, /var is a symlink to /private/var, so paths must be resolved. // Returns the original path if symlink resolution fails. func ResolvePath(path string) string { resolved, err := filepath.EvalSymlinks(path) if err != nil { return path } return resolved } // MoveFile attempts to rename a file from source to destination, if this fails // due to invalid cross-device link it falls back to copying the file contents // and deleting the original file. func MoveFile(source string, destination string) error { if renameErr := os.Rename(source, destination); renameErr != nil { var sysErr syscall.Errno if errors.As(renameErr, &sysErr) && sysErr == syscall.EXDEV { if moveErr := CopyFile(source, destination); moveErr != nil { return moveErr } return os.Remove(source) } return renameErr } return nil } // SkipDirIfIgnorable checks if an entire directory should be skipped based on the fact that it's // in a directory that should never have components discovered in it. func SkipDirIfIgnorable(dir string) error { switch dir { case GitDir, TerraformCacheDir, TerragruntCacheDir: return filepath.SkipDir } return nil } ================================================ FILE: internal/util/file_test.go ================================================ package util_test import ( "errors" "fmt" "io/fs" "os" "path" "path/filepath" "sort" "strconv" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetPathRelativeTo(t *testing.T) { t.Parallel() testCases := []struct { path string basePath string expected string }{ {"", "", "."}, {helpers.RootFolder, helpers.RootFolder, "."}, {helpers.RootFolder, helpers.RootFolder + "child", ".."}, {helpers.RootFolder, helpers.RootFolder + "child/sub-child/sub-sub-child", "../../.."}, {helpers.RootFolder + "other-child", helpers.RootFolder + "child", "../other-child"}, {helpers.RootFolder + "other-child/sub-child", helpers.RootFolder + "child/sub-child", "../../other-child/sub-child"}, {helpers.RootFolder + "root", helpers.RootFolder + "other-root", "../root"}, {helpers.RootFolder + "root", helpers.RootFolder + "other-root/sub-child/sub-sub-child", "../../../root"}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual, err := util.GetPathRelativeTo(tc.path, tc.basePath) require.NoError(t, err) assert.Equal(t, tc.expected, actual, "For path %s and basePath %s", tc.path, tc.basePath) }) } } func TestCanonicalPath(t *testing.T) { t.Parallel() testCases := []struct { path string basePath string expected string }{ {"", helpers.RootFolder + "foo", helpers.RootFolder + "foo"}, {".", helpers.RootFolder + "foo", helpers.RootFolder + "foo"}, {"bar", helpers.RootFolder + "foo", helpers.RootFolder + "foo/bar"}, {"bar/baz/blah", helpers.RootFolder + "foo", helpers.RootFolder + "foo/bar/baz/blah"}, {"bar/../blah", helpers.RootFolder + "foo", helpers.RootFolder + "foo/blah"}, {"bar/../..", helpers.RootFolder + "foo", helpers.RootFolder}, {"bar/.././../baz", helpers.RootFolder + "foo", helpers.RootFolder + "baz"}, {"bar", helpers.RootFolder + "foo/../baz", helpers.RootFolder + "baz/bar"}, {"a/b/../c/d/..", helpers.RootFolder + "foo/../baz/.", helpers.RootFolder + "baz/a/c"}, {helpers.RootFolder + "other", helpers.RootFolder + "foo", helpers.RootFolder + "other"}, {helpers.RootFolder + "other/bar/blah", helpers.RootFolder + "foo", helpers.RootFolder + "other/bar/blah"}, {helpers.RootFolder + "other/../blah", helpers.RootFolder + "foo", helpers.RootFolder + "blah"}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual, err := util.CanonicalPath(tc.path, tc.basePath) require.NoError(t, err) assert.Equal(t, tc.expected, actual, "For path %s and basePath %s", tc.path, tc.basePath) }) } } func TestPathContainsHiddenFileOrFolder(t *testing.T) { t.Parallel() testCases := []struct { path string expected bool }{ {"", false}, {".", false}, {".foo", true}, {".foo/", true}, {"foo/bar", false}, {"/foo/bar", false}, {".foo/bar", true}, {"foo/.bar", true}, {"/foo/.bar", true}, {"/foo/./bar", false}, {"/foo/../bar", false}, {"/foo/.././bar", false}, {"/foo/.././.bar", true}, {"/foo/.././.bar/", true}, } for _, tc := range testCases { t.Run(tc.path, func(t *testing.T) { t.Parallel() path := filepath.FromSlash(tc.path) actual := util.TerragruntExcludes(path) assert.Equal(t, tc.expected, actual, "For path %s", path) }) } } func TestJoinTerraformModulePath(t *testing.T) { t.Parallel() testCases := []struct { modulesFolder string path string expected string }{ {"foo", "bar", "foo//bar"}, {"foo/", "bar", "foo//bar"}, {"foo", "/bar", "foo//bar"}, {"foo/", "/bar", "foo//bar"}, {"foo//", "/bar", "foo//bar"}, {"foo//", "//bar", "foo//bar"}, {"/foo/bar/baz", "/a/b/c", "/foo/bar/baz//a/b/c"}, {"/foo/bar/baz/", "//a/b/c", "/foo/bar/baz//a/b/c"}, {"/foo?ref=feature/1", "bar", "/foo//bar?ref=feature/1"}, {"/foo?ref=feature/1", "/bar", "/foo//bar?ref=feature/1"}, {"/foo//?ref=feature/1", "/bar", "/foo//bar?ref=feature/1"}, {"/foo//?ref=feature/1", "//bar", "/foo//bar?ref=feature/1"}, {"/foo/bar/baz?ref=feature/1", "/a/b/c", "/foo/bar/baz//a/b/c?ref=feature/1"}, {"/foo/bar/baz/?ref=feature/1", "//a/b/c", "/foo/bar/baz//a/b/c?ref=feature/1"}, } for _, tc := range testCases { t.Run(fmt.Sprintf("%s-%s", tc.modulesFolder, tc.path), func(t *testing.T) { t.Parallel() actual := util.JoinTerraformModulePath(tc.modulesFolder, tc.path) assert.Equal(t, tc.expected, actual) }) } } func TestFileManifest(t *testing.T) { t.Parallel() files := []string{"file1", "file2"} var testfiles = make([]string, 0, len(files)) // create temp dir dir := helpers.TmpDirWOSymlinks(t) for _, file := range files { // create temp files in the dir f, err := os.CreateTemp(dir, file) require.NoError(t, err) // Close the file handle immediately after creation require.NoError(t, f.Close()) testfiles = append(testfiles, f.Name()) } // will later test if the file already doesn't exist testfiles = append(testfiles, path.Join(dir, "ephemeral-file-that-doesnt-exist.txt")) // create a manifest l := logger.CreateLogger() manifest := util.NewFileManifest(dir, ".terragrunt-test-manifest") require.NoError(t, manifest.Create()) // check the file manifest has been created assert.FileExists(t, filepath.Join(manifest.ManifestFolder, manifest.ManifestFile)) for _, file := range testfiles { require.NoError(t, manifest.AddFile(file)) } // check for a non-existent directory as well assert.NoError(t, manifest.AddDirectory(path.Join(dir, "ephemeral-directory-that-doesnt-exist"))) // Close the manifest file handle before cleaning require.NoError(t, manifest.Close()) assert.NoError(t, manifest.Clean(l)) // test if the files have been deleted for _, file := range testfiles { assert.False(t, util.FileExists(file)) } } func TestContainsPath(t *testing.T) { t.Parallel() testCases := []struct { path string subpath string expected bool }{ {"", "", true}, {"/", "/", true}, {"foo/bar/.tf/tg.hcl", "foo/bar", true}, {"/foo/bar/.tf/tg.hcl", "foo/bar", true}, {"foo/bar/.tf/tg.hcl", "bar", true}, {"foo/bar/.tf/tg.hcl", ".tf/tg.hcl", true}, {"foo/bar/.tf/tg.hcl", "tg.hcl", true}, {"foo/bar/.tf/tg.hcl", "/bar", false}, {"/foo/bar/.tf/tg.hcl", "/bar", false}, {"foo/bar", "foo/bar/gee", false}, {"foo/bar/.tf/tg.hcl", "foo/barf", false}, {"foo/bar/.tf/tg.hcl", "foo/ba", false}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.ContainsPath(tc.path, tc.subpath) assert.Equal(t, tc.expected, actual, "For path %s and subpath %s", tc.path, tc.subpath) }) } } func TestHasPathPrefix(t *testing.T) { t.Parallel() testCases := []struct { path string prefix string expected bool }{ {"", "", true}, {"/", "/", true}, {"foo/bar/.tf/tg.hcl", "foo", true}, {"/foo/bar/.tf/tg.hcl", "/foo", true}, {"foo/bar/.tf/tg.hcl", "foo/bar", true}, {"/foo/bar/.tf/tg.hcl", "/foo/bar", true}, {"/", "", false}, {"foo", "foo/bar/.tf/tg.hcl", false}, {"/foo/bar/.tf/tg.hcl", "foo", false}, {"/foo/bar/.tf/tg.hcl", "bar/.tf", false}, {"/foo/bar/.tf/tg.hcl", "/foo/barf", false}, {"/foo/bar/.tf/tg.hcl", "/foo/ba", false}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.HasPathPrefix(tc.path, tc.prefix) assert.Equal(t, tc.expected, actual, "For path %s and prefix %s", tc.path, tc.prefix) }) } } func TestIncludeInCopy(t *testing.T) { t.Parallel() includeInCopy := []string{"_module/.region2", "**/app2", "**/.include-me-too"} testCases := []struct { path string copyExpected bool }{ {"/app/terragrunt.hcl", true}, {"/_module/main.tf", true}, {"/_module/.region1/info.txt", false}, {"/_module/.region3/project3-1/f1-2-levels.txt", false}, {"/_module/.region3/project3-1/app1/.include-me-too/file.txt", true}, {"/_module/.region3/project3-2/.f0/f0-3-levels.txt", false}, {"/_module/.region2/.project2-1/app2/f2-dot-f2.txt", true}, {"/_module/.region2/.project2-1/readme.txt", true}, {"/_module/.region2/project2-2/f2-dot-f0.txt", true}, } tempDir := helpers.TmpDirWOSymlinks(t) source := filepath.Join(tempDir, "source") destination := filepath.Join(tempDir, "destination") fileContent := []byte("source file") for _, tc := range testCases { path := filepath.Join(source, tc.path) assert.NoError(t, os.MkdirAll(filepath.Dir(path), os.ModePerm)) assert.NoError(t, os.WriteFile(path, fileContent, 0644)) } require.NoError(t, util.CopyFolderContents(logger.CreateLogger(), source, destination, ".terragrunt-test", includeInCopy, nil)) for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() _, err := os.Stat(filepath.Join(destination, tc.path)) assert.True(t, tc.copyExpected && err == nil || !tc.copyExpected && errors.Is(err, os.ErrNotExist), "Unexpected copy result for file '%s' (should be copied: '%t') - got error: %s", tc.path, tc.copyExpected, err) }) } } func TestExcludeFromCopy(t *testing.T) { t.Parallel() excludeFromCopy := []string{"module/region2", "**/exclude-me-here", "**/app1"} testCases := []struct { path string copyExpected bool }{ {"/app/terragrunt.hcl", true}, {"/module/main.tf", true}, {"/module/region1/info.txt", true}, {"/module/region1/project2-1/app1/f2-dot-f2.txt", false}, {"/module/region3/project3-1/f1-2-levels.txt", true}, {"/module/region3/project3-1/app1/exclude-me-here/file.txt", false}, {"/module/region3/project3-2/f0/f0-3-levels.txt", true}, {"/module/region2/project2-1/app2/f2-dot-f2.txt", false}, {"/module/region2/project2-1/readme.txt", false}, {"/module/region2/project2-2/f2-dot-f0.txt", false}, } tempDir := helpers.TmpDirWOSymlinks(t) source := filepath.Join(tempDir, "source") destination := filepath.Join(tempDir, "destination") fileContent := []byte("source file") for _, tc := range testCases { path := filepath.Join(source, tc.path) assert.NoError(t, os.MkdirAll(filepath.Dir(path), os.ModePerm)) assert.NoError(t, os.WriteFile(path, fileContent, 0644)) } require.NoError(t, util.CopyFolderContents(logger.CreateLogger(), source, destination, ".terragrunt-test", nil, excludeFromCopy)) for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() _, err := os.Stat(filepath.Join(destination, tc.path)) assert.True(t, tc.copyExpected && err == nil || !tc.copyExpected && errors.Is(err, os.ErrNotExist), "Unexpected copy result for file '%s' (should be copied: '%t') - got error: %s", tc.path, tc.copyExpected, err) }) } } func TestExcludeIncludeBehaviourPriority(t *testing.T) { t.Parallel() includeInCopy := []string{"_module/.region2", "_module/.region3"} excludeFromCopy := []string{"**/.project2-2", "_module/.region3"} testCases := []struct { path string copyExpected bool }{ {"/_module/.region2/.project2-1/app2/f2-dot-f2.txt", true}, {"/_module/.region2/.project2-1/readme.txt", true}, {"/_module/.region2/.project2-2/f2-dot-f0.txt", false}, {"/_module/.region3/.project2-1/readme.txt", false}, } tempDir := helpers.TmpDirWOSymlinks(t) source := filepath.Join(tempDir, "source") destination := filepath.Join(tempDir, "destination") fileContent := []byte("source file") for _, tc := range testCases { path := filepath.Join(source, tc.path) assert.NoError(t, os.MkdirAll(filepath.Dir(path), os.ModePerm)) assert.NoError(t, os.WriteFile(path, fileContent, 0644)) } require.NoError(t, util.CopyFolderContents(logger.CreateLogger(), source, destination, ".terragrunt-test", includeInCopy, excludeFromCopy)) for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() _, err := os.Stat(filepath.Join(destination, tc.path)) assert.True(t, tc.copyExpected && err == nil || !tc.copyExpected && errors.Is(err, os.ErrNotExist), "Unexpected copy result for file '%s' (should be copied: '%t') - got error: %s", tc.path, tc.copyExpected, err) }) } } func TestEmptyDir(t *testing.T) { t.Parallel() testCases := []struct { path string expectEmpty bool }{ {helpers.TmpDirWOSymlinks(t), true}, {os.TempDir(), false}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() emptyValue, err := util.IsDirectoryEmpty(tc.path) require.NoError(t, err) assert.Equal(t, tc.expectEmpty, emptyValue, "For path %s", tc.path) }) } } //nolint:funlen func TestWalkWithSimpleSymlinks(t *testing.T) { t.Parallel() // Create a temporary test directory structure tempDir := helpers.TmpDirWOSymlinks(t) tempDir, err := filepath.EvalSymlinks(tempDir) require.NoError(t, err) // Create directories dirs := []string{"a", "d"} for _, dir := range dirs { require.NoError(t, os.Mkdir(filepath.Join(tempDir, dir), 0755)) } // Create test files testFile := filepath.Join(tempDir, "a", "test.txt") require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644)) // Create symlinks require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "b"))) require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "c"))) require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "d", "a"))) var paths []string err = util.WalkDirWithSymlinks(tempDir, func(path string, _ fs.DirEntry, err error) error { if err != nil { return err } relPath, err := filepath.Rel(tempDir, path) if err != nil { t.Fatal(err) } paths = append(paths, relPath) return nil }) require.NoError(t, err) // Sort paths for reliable comparison sort.Strings(paths) // Expected paths should include original and symlinked locations expectedPaths := []string{ ".", "a", filepath.Join("a", "test.txt"), "b", filepath.Join("b", "test.txt"), "c", filepath.Join("c", "test.txt"), "d", filepath.Join("d", "a"), filepath.Join("d", "a", "test.txt"), } sort.Strings(expectedPaths) if len(paths) != len(expectedPaths) { t.Errorf("Got %d paths, expected %d", len(paths), len(expectedPaths)) } for expectedPath := range expectedPaths { if expectedPath >= len(paths) { t.Errorf("Missing expected path: %s", expectedPaths[expectedPath]) continue } if paths[expectedPath] != expectedPaths[expectedPath] { t.Errorf("Path mismatch at index %d:\ngot: %s\nwant: %s", expectedPath, paths[expectedPath], expectedPaths[expectedPath]) } } } //nolint:funlen func TestWalkWithCircularSymlinks(t *testing.T) { t.Parallel() // Create temporary test directory structure tempDir := helpers.TmpDirWOSymlinks(t) tempDir, err := filepath.EvalSymlinks(tempDir) require.NoError(t, err) // Create directories dirs := []string{"a", "b", "c", "d"} for _, dir := range dirs { require.NoError(t, os.Mkdir(filepath.Join(tempDir, dir), 0755)) } // Create test files testFile := filepath.Join(tempDir, "a", "test.txt") require.NoError(t, os.WriteFile(testFile, []byte("test"), 0644)) // Create symlinks require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "b", "link-to-a"))) require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "c", "another-link-to-a"))) // Create circular symlink require.NoError(t, os.Symlink(filepath.Join(tempDir, "d"), filepath.Join(tempDir, "a", "link-to-d"))) require.NoError(t, os.Symlink(filepath.Join(tempDir, "a"), filepath.Join(tempDir, "d", "link-to-a"))) var paths []string err = util.WalkDirWithSymlinks(tempDir, func(path string, _ fs.DirEntry, err error) error { if err != nil { return err } relPath, err := filepath.Rel(tempDir, path) if err != nil { t.Fatal(err) } paths = append(paths, relPath) return nil }) require.NoError(t, err) // Sort paths for reliable comparison sort.Strings(paths) // Expected paths should include original and symlinked locations expectedPaths := []string{ ".", "a", filepath.Join("a", "link-to-d"), filepath.Join("a", "link-to-d", "link-to-a"), filepath.Join("a", "link-to-d", "link-to-a", "link-to-d"), filepath.Join("a", "link-to-d", "link-to-a", "test.txt"), filepath.Join("a", "test.txt"), "b", filepath.Join("b", "link-to-a"), filepath.Join("b", "link-to-a", "link-to-d"), filepath.Join("b", "link-to-a", "test.txt"), "c", filepath.Join("c", "another-link-to-a"), filepath.Join("c", "another-link-to-a", "link-to-d"), filepath.Join("c", "another-link-to-a", "test.txt"), "d", filepath.Join("d", "link-to-a"), } sort.Strings(expectedPaths) if len(paths) != len(expectedPaths) { t.Errorf("Got %d paths, expected %d", len(paths), len(expectedPaths)) } for expectedPath := range expectedPaths { if expectedPath >= len(paths) { t.Errorf("Missing expected path: %s", expectedPaths[expectedPath]) continue } if paths[expectedPath] != expectedPaths[expectedPath] { t.Errorf("Path mismatch at index %d:\ngot: %s\nwant: %s", expectedPath, paths[expectedPath], expectedPaths[expectedPath]) } } } func TestWalkDirWithSymlinksErrors(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) // Test with non-existent directory require.Error(t, util.WalkDirWithSymlinks(filepath.Join(tempDir, "nonexistent"), func(_ string, _ fs.DirEntry, err error) error { return err })) // Test with broken symlink brokenLink := filepath.Join(tempDir, "broken") require.NoError(t, os.Symlink(filepath.Join(tempDir, "nonexistent"), brokenLink)) require.Error(t, util.WalkDirWithSymlinks(tempDir, func(_ string, _ fs.DirEntry, err error) error { return err })) } func Test_sanitizePath(t *testing.T) { t.Parallel() tests := []struct { name string baseDir string file string want string wantErr bool }{ { name: "happy path", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: ".terraform-version", want: "./testdata/fixture-sanitize-path/env/unit/.terraform-version", }, { name: "base dir is empty", baseDir: "", file: ".terraform-version", want: "", wantErr: true, }, { name: "try to escape base dir", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: "../../../dev/random", want: "", wantErr: true, }, { name: "file is empty", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: "", want: "", wantErr: true, }, { name: "file is just a slash", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: "/", want: "", wantErr: true, }, { name: "file is just a dot", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: ".", want: "./testdata/fixture-sanitize-path/env/unit/.", wantErr: false, }, { name: "encoded characters", baseDir: "./testdata/fixture-sanitize-path/env/unit", file: "..%2F..%2Fetc%2Fpasswd", want: "", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := util.SanitizePath(tt.baseDir, tt.file) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equalf(t, tt.want, got, "sanitizePath(%v, %v)", tt.baseDir, tt.file) }) } } func TestMoveFile(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) src := filepath.Join(tempDir, "src.txt") dst := filepath.Join(tempDir, "dst.txt") require.NoError(t, os.WriteFile(src, []byte("test"), 0644)) require.NoError(t, util.MoveFile(src, dst)) // Verify the file was moved _, err := os.Stat(src) require.True(t, os.IsNotExist(err)) contents, err := os.ReadFile(dst) require.NoError(t, err) assert.Equal(t, "test", string(contents)) } func TestRelPathForLog(t *testing.T) { t.Parallel() testCases := []struct { name string basePath string targetPath string expected string showAbsPath bool }{ { name: "showAbsPath true returns targetPath unchanged", basePath: helpers.RootFolder + "base", targetPath: helpers.RootFolder + "base/child/file.txt", showAbsPath: true, expected: helpers.RootFolder + "base/child/file.txt", }, { name: "same path returns targetPath", basePath: helpers.RootFolder + "base", targetPath: helpers.RootFolder + "base", showAbsPath: false, expected: helpers.RootFolder + "base", }, { name: "child path gets ./ prefix", basePath: helpers.RootFolder + "base", targetPath: helpers.RootFolder + "base/child", showAbsPath: false, expected: "." + string(filepath.Separator) + "child", }, { name: "nested child path gets ./ prefix", basePath: helpers.RootFolder + "base", targetPath: helpers.RootFolder + "base/child/subchild/file.txt", showAbsPath: false, expected: "." + string(filepath.Separator) + filepath.Join("child", "subchild", "file.txt"), }, { name: "parent path returns relative path with ..", basePath: helpers.RootFolder + "base/child", targetPath: helpers.RootFolder + "base", showAbsPath: false, expected: "..", }, { name: "sibling path returns relative path with ..", basePath: helpers.RootFolder + "base/child1", targetPath: helpers.RootFolder + "base/child2", showAbsPath: false, expected: ".." + string(filepath.Separator) + "child2", }, { name: "deeply nested sibling path", basePath: helpers.RootFolder + "base/a/b/c", targetPath: helpers.RootFolder + "base/x/y/z", showAbsPath: false, expected: ".." + string(filepath.Separator) + ".." + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Join("x", "y", "z"), }, { name: "unrelated paths at different roots", basePath: helpers.RootFolder + "foo", targetPath: helpers.RootFolder + "bar/baz", showAbsPath: false, expected: ".." + string(filepath.Separator) + filepath.Join("bar", "baz"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() actual := util.RelPathForLog(tc.basePath, tc.targetPath, tc.showAbsPath) assert.Equal(t, tc.expected, actual, "For basePath %s and targetPath %s", tc.basePath, tc.targetPath) }) } } ================================================ FILE: internal/util/file_tofu_test.go ================================================ package util_test import ( "os" "path/filepath" "regexp" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( benchmarkBoolSink bool ) func TestIsTFFile(t *testing.T) { t.Parallel() testCases := []struct { description string path string expected bool }{ { description: "Terraform .tf file", path: "main.tf", expected: true, }, { description: "OpenTofu .tofu file", path: "main.tofu", expected: true, }, { description: "Terraform JSON .tf.json file", path: "main.tf.json", expected: true, }, { description: "OpenTofu JSON .tofu.json file", path: "main.tofu.json", expected: true, }, { description: "Regular JSON file", path: "config.json", expected: false, }, { description: "Regular text file", path: "readme.txt", expected: false, }, { description: "No extension", path: "Dockerfile", expected: false, }, { description: "HCL file (not Terraform/OpenTofu)", path: "terragrunt.hcl", expected: false, }, { description: "Path with directories - .tf file", path: "/path/to/modules/main.tf", expected: true, }, { description: "Path with directories - .tofu file", path: "/path/to/modules/main.tofu", expected: true, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() actual := util.IsTFFile(tc.path) assert.Equal(t, tc.expected, actual, "For path %s", tc.path) }) } } func TestDirContainsTFFiles(t *testing.T) { t.Parallel() testCases := []struct { description string files []string directories []string expected bool expectError bool }{ { description: "Directory with .tf file", files: []string{"main.tf"}, expected: true, }, { description: "Directory with .tofu file", files: []string{"main.tofu"}, expected: true, }, { description: "Directory with .tf.json file", files: []string{"main.tf.json"}, expected: true, }, { description: "Directory with .tofu.json file", files: []string{"main.tofu.json"}, expected: true, }, { description: "Directory with both .tf and .tofu files", files: []string{"main.tf", "variables.tofu"}, expected: true, }, { description: "Directory with mixed file types including TF files", files: []string{"main.tf", "readme.txt", "config.json"}, expected: true, }, { description: "Directory with no TF files", files: []string{"readme.txt", "config.json", "script.sh"}, expected: false, }, { description: "Empty directory", files: []string{}, expected: false, }, { description: "Directory with subdirectories containing TF files", files: []string{"modules/main.tf", "data/variables.tofu"}, directories: []string{"modules", "data"}, expected: true, }, { description: "Directory with only non-TF files in subdirectories", files: []string{"modules/readme.txt", "data/config.json"}, directories: []string{"modules", "data"}, expected: false, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() // Create temporary directory tmpDir := helpers.TmpDirWOSymlinks(t) // Create directories for _, dir := range tc.directories { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) } // Create files for _, file := range tc.files { filePath := filepath.Join(tmpDir, file) // Ensure directory exists require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) require.NoError(t, os.WriteFile(filePath, []byte("# Test file content"), 0644)) } actual, err := util.DirContainsTFFiles(tmpDir) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, actual, "For test case: %s", tc.description) } }) } } func TestFindTFFiles(t *testing.T) { t.Parallel() testCases := []struct { description string files []string directories []string expectedFiles []string expectedCount int }{ { description: "Directory with single .tf file", files: []string{"main.tf"}, expectedCount: 1, expectedFiles: []string{"main.tf"}, }, { description: "Directory with single .tofu file", files: []string{"main.tofu"}, expectedCount: 1, expectedFiles: []string{"main.tofu"}, }, { description: "Directory with mixed TF file types", files: []string{"main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json"}, expectedCount: 4, expectedFiles: []string{"main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json"}, }, { description: "Directory with TF and non-TF files", files: []string{"main.tf", "readme.txt", "variables.tofu", "config.json"}, expectedCount: 2, expectedFiles: []string{"main.tf", "variables.tofu"}, }, { description: "Empty directory", files: []string{}, expectedCount: 0, expectedFiles: []string{}, }, { description: "Directory with only non-TF files", files: []string{"readme.txt", "config.json", "script.sh"}, expectedCount: 0, expectedFiles: []string{}, }, { description: "Directory with nested TF files", files: []string{"main.tf", "modules/vpc/main.tofu", "modules/security/variables.tf.json"}, directories: []string{"modules", "modules/vpc", "modules/security"}, expectedCount: 3, expectedFiles: []string{"main.tf", "modules/vpc/main.tofu", "modules/security/variables.tf.json"}, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() // Create temporary directory tmpDir := helpers.TmpDirWOSymlinks(t) // Create directories for _, dir := range tc.directories { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) } // Create files for _, file := range tc.files { filePath := filepath.Join(tmpDir, file) // Ensure directory exists require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) require.NoError(t, os.WriteFile(filePath, []byte("# Test file content"), 0644)) } actual, err := util.FindTFFiles(tmpDir) require.NoError(t, err) // Check count assert.Len(t, actual, tc.expectedCount, "Expected %d files, got %d", tc.expectedCount, len(actual)) // Check that all expected files are found (convert to relative paths for comparison) expectedRelativePaths := make([]string, len(tc.expectedFiles)) for i, expectedFile := range tc.expectedFiles { expectedRelativePaths[i] = filepath.Join(tmpDir, expectedFile) } for _, expectedPath := range expectedRelativePaths { assert.Contains(t, actual, expectedPath, "Expected file %s not found in results", expectedPath) } // Check that all found files are actually TF files for _, foundFile := range actual { assert.True(t, util.IsTFFile(foundFile), "Non-TF file %s found in results", foundFile) } }) } } func TestRegexFoundInTFFiles(t *testing.T) { t.Parallel() testCases := []struct { files map[string]string description string pattern string directories []string expected bool }{ { description: "Pattern found in .tf file", files: map[string]string{ "main.tf": ` terraform { backend "s3" { bucket = "my-bucket" } }`, }, pattern: `backend[[:blank:]]+"s3"`, expected: true, }, { description: "Pattern found in .tofu file", files: map[string]string{ "main.tofu": ` terraform { backend "local" { path = "terraform.tfstate" } }`, }, pattern: `backend[[:blank:]]+"local"`, expected: true, }, { description: "Pattern found in .tf.json file", files: map[string]string{ "main.tf.json": `{ "terraform": { "backend": { "remote": { "organization": "my-org" } } } }`, }, pattern: `"backend":[[:space:]]*{[[:space:]]*"remote"`, expected: true, }, { description: "Pattern found in .tofu.json file", files: map[string]string{ "main.tofu.json": `{ "terraform": { "backend": { "gcs": { "bucket": "my-bucket" } } } }`, }, pattern: `"backend":[[:space:]]*{[[:space:]]*"gcs"`, expected: true, }, { description: "Pattern found in mixed file types", files: map[string]string{ "main.tf": "# No backend here", "backend.tofu": `terraform { backend "s3" {} }`, "readme.txt": "This is not a TF file", "config.json": `{"not": "terraform"}`, }, pattern: `backend[[:blank:]]+"s3"`, expected: true, }, { description: "Pattern not found in any TF files", files: map[string]string{ "main.tf": "resource \"aws_instance\" \"example\" {}", "vars.tofu": "variable \"name\" { type = string }", "readme.txt": "This file contains backend configuration (but it's not a TF file)", }, pattern: `backend[[:blank:]]+"s3"`, expected: false, }, { description: "Pattern found in nested TF files", files: map[string]string{ "main.tf": "# No backend", "modules/vpc/main.tofu": `terraform { backend "s3" {} }`, "modules/security/vars.tf": "variable \"vpc_id\" {}", }, directories: []string{"modules", "modules/vpc", "modules/security"}, pattern: `backend[[:blank:]]+"s3"`, expected: true, }, { description: "Module pattern found", files: map[string]string{ "main.tf": ` module "vpc" { source = "./modules/vpc" }`, }, pattern: `module[[:blank:]]+".+"`, expected: true, }, { description: "Module pattern found in .tofu file", files: map[string]string{ "infrastructure.tofu": ` module "database" { source = "git::https://github.com/example/modules.git//database" }`, }, pattern: `module[[:blank:]]+".+"`, expected: true, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() // Create temporary directory tmpDir := helpers.TmpDirWOSymlinks(t) // Create directories for _, dir := range tc.directories { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) } // Create files with content for filename, content := range tc.files { filePath := filepath.Join(tmpDir, filename) // Ensure directory exists require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) } // Compile regex pattern regex, err := regexp.Compile(tc.pattern) require.NoError(t, err) actual, err := util.RegexFoundInTFFiles(tmpDir, regex) require.NoError(t, err) assert.Equal(t, tc.expected, actual, "For test case: %s", tc.description) }) } } // TestRegexFoundInTFFilesErrorHandling tests error conditions func TestRegexFoundInTFFilesErrorHandling(t *testing.T) { t.Parallel() t.Run("Non-existent directory", func(t *testing.T) { t.Parallel() regex := regexp.MustCompile("test") _, err := util.RegexFoundInTFFiles("/non/existent/directory", regex) assert.Error(t, err) }) t.Run("Permission denied file", func(t *testing.T) { t.Parallel() // Create a directory and file, then remove read permissions tmpDir := helpers.TmpDirWOSymlinks(t) testFile := filepath.Join(tmpDir, "test.tf") require.NoError(t, os.WriteFile(testFile, []byte("content"), 0644)) // Remove read permissions (this test might not work on all systems/CI environments) err := os.Chmod(testFile, 0000) if err != nil { t.Skip("Cannot change file permissions on this system") } // Restore permissions after test defer func() { _ = os.Chmod(testFile, 0644) }() regex := regexp.MustCompile("test") _, err = util.RegexFoundInTFFiles(tmpDir, regex) // We expect an error due to permission denied, but don't fail the test // if the OS doesn't enforce permission restrictions in the test environment if err == nil { t.Log("Permission restrictions not enforced in test environment") } }) } func TestListTfFiles(t *testing.T) { t.Parallel() testCases := []struct { description string files []string directories []string expectedFiles []string expectedCount int }{ { description: "Directory with .tf files only", files: []string{"main.tf", "variables.tf"}, expectedCount: 2, expectedFiles: []string{"main.tf", "variables.tf"}, }, { description: "Directory with .tofu files only", files: []string{"main.tofu", "variables.tofu"}, expectedCount: 2, expectedFiles: []string{"main.tofu", "variables.tofu"}, }, { description: "Directory with mixed .tf and .tofu files", files: []string{"main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json"}, expectedCount: 4, expectedFiles: []string{"main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json"}, }, { description: "Directory with TF and non-TF files", files: []string{"main.tofu", "readme.txt", "config.json"}, expectedCount: 1, expectedFiles: []string{"main.tofu"}, }, { description: "Empty directory", files: []string{}, expectedCount: 0, expectedFiles: []string{}, }, { description: "Directory with nested .tofu files", files: []string{"main.tf", "modules/vpc/main.tofu"}, directories: []string{"modules", "modules/vpc"}, expectedCount: 2, expectedFiles: []string{"main.tf", "modules/vpc/main.tofu"}, }, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) for _, dir := range tc.directories { dirPath := filepath.Join(tmpDir, dir) require.NoError(t, os.MkdirAll(dirPath, 0755)) } for _, file := range tc.files { filePath := filepath.Join(tmpDir, file) require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755)) require.NoError(t, os.WriteFile(filePath, []byte("# Test file content"), 0644)) } actual, err := util.ListTfFiles(tmpDir, false) require.NoError(t, err) assert.Len(t, actual, tc.expectedCount, "Expected %d files, got %d", tc.expectedCount, len(actual)) for _, expectedFile := range tc.expectedFiles { expectedPath := filepath.Join(tmpDir, expectedFile) assert.Contains(t, actual, expectedPath, "Expected file %s not found in results", expectedPath) } for _, foundFile := range actual { assert.True(t, util.IsTFFile(foundFile), "Non-TF file %s found in results", foundFile) } }) } } func TestListTfFilesWithSymlinks(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create a real directory with .tofu files realDir := filepath.Join(tmpDir, "real-module") require.NoError(t, os.MkdirAll(realDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(realDir, "main.tofu"), []byte("# main"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(realDir, "variables.tf"), []byte("# vars"), 0644)) // Create a symlink to the real directory linkDir := filepath.Join(tmpDir, "linked-module") require.NoError(t, os.Symlink(realDir, linkDir)) // walkWithSymlinks=true should follow symlinks and find files actual, err := util.ListTfFiles(tmpDir, true) require.NoError(t, err) // Should find files in both real dir and symlinked dir assert.Len(t, actual, 4) for _, foundFile := range actual { assert.True(t, util.IsTFFile(foundFile), "Non-TF file %s found in results", foundFile) } } // Benchmark tests to ensure performance is reasonable func BenchmarkIsTFFile(b *testing.B) { testPaths := []string{ "main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json", "readme.txt", "config.json", "/very/long/path/to/terraform/modules/vpc/main.tf", "/very/long/path/to/opentofu/modules/database/variables.tofu", } b.ReportAllocs() b.ResetTimer() for b.Loop() { for _, path := range testPaths { if util.IsTFFile(path) { benchmarkBoolSink = !benchmarkBoolSink } } } } func BenchmarkDirContainsTFFiles(b *testing.B) { // Create a temporary directory with mixed files for benchmarking tmpDir := b.TempDir() files := []string{ "main.tf", "variables.tofu", "outputs.tf.json", "providers.tofu.json", "readme.txt", "config.json", "modules/vpc/main.tf", "modules/database/vars.tofu", } for _, file := range files { filePath := filepath.Join(tmpDir, file) require.NoError(b, os.MkdirAll(filepath.Dir(filePath), 0755)) require.NoError(b, os.WriteFile(filePath, []byte("# Test content"), 0644)) } b.ReportAllocs() b.ResetTimer() for b.Loop() { result, err := util.DirContainsTFFiles(tmpDir) require.NoError(b, err) benchmarkBoolSink = benchmarkBoolSink != result } } ================================================ FILE: internal/util/hash.go ================================================ package util import ( "crypto/rand" "crypto/sha1" "crypto/sha256" "encoding/base64" "fmt" ) const ( sha256InputSize = 32 ) // EncodeBase64Sha1 Returns the base 64 encoded sha1 hash of the given string func EncodeBase64Sha1(str string) string { hash := sha1.Sum([]byte(str)) return base64.RawURLEncoding.EncodeToString(hash[:]) } func GenerateRandomSha256() (string, error) { randomBytes := make([]byte, sha256InputSize) _, err := rand.Read(randomBytes) if err != nil { return "", err } return fmt.Sprintf("%x", sha256.Sum256(randomBytes)), nil } ================================================ FILE: internal/util/jsons.go ================================================ package util import ( "encoding/json" "fmt" "strings" ) // interpolationEscaper replaces unescaped HCL interpolation patterns (${...}) with // their escaped form ($${...}). Listing $${ first makes the replacement idempotent: // already-escaped $${...} is matched at that position before ${ is tried, so it is // emitted unchanged. var interpolationEscaper = strings.NewReplacer("$${", "$${", "${", "$${") // AsTerraformEnvVarJSONValue converts the given value to a JSON value that can be passed to // OpenTofu/Terraform as an environment variable. For the most part, this converts the value directly // to JSON using Go's built-in json.Marshal. However, we have special handling // for strings, which with normal JSON conversion would be wrapped in quotes, but when passing them to OpenTofu/Terraform via // env vars, we need to NOT wrap them in quotes, so this method adds special handling for that case. // For complex types (maps, lists, objects), string values containing ${...} patterns are escaped to $${...} // to prevent OpenTofu/Terraform's HCL parser from treating them as variable interpolations. func AsTerraformEnvVarJSONValue(value any) (string, error) { switch val := value.(type) { case string: return val, nil default: escaped, err := escapeInterpolationPatternsInValue(val, 0) if err != nil { return "", err } envVarValue, err := json.Marshal(escaped) if err != nil { return "", err } return string(envVarValue), nil } } // escapeInterpolationPatternsInValue recursively walks a value tree and escapes // HCL interpolation patterns (${...}) in string values to prevent OpenTofu/Terraform from // treating them as variable references when parsing complex type TF_VAR_* env vars. // // This unconditionally escapes ${...} in all string values within complex types. // This is intentional: OpenTofu/Terraform's HCL parser would error on unescaped ${...} in // complex TF_VAR values anyway (behaviour change: previously errored; now passes the // literal value through). // // Nil maps and slices are preserved as nil so json.Marshal serializes them as null // rather than {} or [], keeping OpenTofu/Terraform's null-vs-empty-collection semantics intact. // // Returns an error if the value tree is deeper than maxDepth (100), which prevents // infinite recursion on malformed inputs. func escapeInterpolationPatternsInValue(value any, depth int) (any, error) { const maxDepth = 100 if depth > maxDepth { return nil, fmt.Errorf("escapeInterpolationPatternsInValue: input exceeds maximum nesting depth of %d", maxDepth) } switch v := value.(type) { case string: return EscapeInterpolationInString(v), nil case map[string]any: if v == nil { return nil, nil } result := make(map[string]any, len(v)) for key, val := range v { escaped, err := escapeInterpolationPatternsInValue(val, depth+1) if err != nil { return nil, err } result[key] = escaped } return result, nil case []any: if v == nil { return nil, nil } result := make([]any, 0, len(v)) for _, val := range v { escaped, err := escapeInterpolationPatternsInValue(val, depth+1) if err != nil { return nil, err } result = append(result, escaped) } return result, nil case []string: if v == nil { return nil, nil } result := make([]any, 0, len(v)) for _, s := range v { result = append(result, EscapeInterpolationInString(s)) } return result, nil case map[string]string: if v == nil { return nil, nil } result := make(map[string]any, len(v)) for k, s := range v { result[k] = EscapeInterpolationInString(s) } return result, nil default: return value, nil } } // EscapeInterpolationInString escapes HCL interpolation patterns (${...}) in a string // by doubling the dollar sign (${ → $${). This is idempotent: already-escaped $${...} // patterns are not double-escaped. func EscapeInterpolationInString(s string) string { return interpolationEscaper.Replace(s) } ================================================ FILE: internal/util/jsons_test.go ================================================ package util_test import ( "fmt" "strconv" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAsTerraformEnvVarJsonValue(t *testing.T) { t.Parallel() testCases := []struct { value any expected string }{ // plain strings: passed through unchanged (Terraform reads string vars as literals) {"aws_region", "aws_region"}, {"plain ${bar} string", "plain ${bar} string"}, // list: JSON serialized, strings within escaped {[]string{"10.0.0.0/16", "10.0.0.10/16"}, "[\"10.0.0.0/16\",\"10.0.0.10/16\"]"}, // map: strings within escaped {map[string]any{"foo": "test ${bar} test"}, `{"foo":"test $${bar} test"}`}, // idempotent: already-escaped $${...} not double-escaped {map[string]any{"foo": "test $${bar} test"}, `{"foo":"test $${bar} test"}`}, // list with interpolation {[]any{"${foo}", "bar"}, `["$${foo}","bar"]`}, // nested map {map[string]any{"a": map[string]any{"b": "${nested}"}}, `{"a":{"b":"$${nested}"}}`}, // typed []string with interpolation {[]string{"${foo}", "bar"}, `["$${foo}","bar"]`}, // typed map[string]string with interpolation {map[string]string{"k": "${foo}"}, `{"k":"$${foo}"}`}, // nil containers must serialize as null, not {} or [] {(map[string]any)(nil), "null"}, {([]any)(nil), "null"}, {([]string)(nil), "null"}, {(map[string]string)(nil), "null"}, // nil inside a complex type must also be null {map[string]any{"list": ([]any)(nil)}, `{"list":null}`}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual, err := util.AsTerraformEnvVarJSONValue(tc.value) require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } } func TestAsTerraformEnvVarJsonValueDepthOverflow(t *testing.T) { t.Parallel() // Build a map nested 102 levels deep — exceeds the maxDepth of 100. deep := buildNestedMap(102) _, err := util.AsTerraformEnvVarJSONValue(deep) require.Error(t, err) assert.Contains(t, err.Error(), "maximum nesting depth") } // buildNestedMap creates a map[string]any with the given nesting depth. func buildNestedMap(depth int) map[string]any { if depth == 0 { return map[string]any{"val": "leaf"} } return map[string]any{"nested": buildNestedMap(depth - 1)} } func TestEscapeInterpolationInString(t *testing.T) { t.Parallel() testCases := []struct { input string expected string }{ {"test ${bar} test", "test $${bar} test"}, // idempotent: already escaped {"test $${bar} test", "test $${bar} test"}, // multiple interpolations {"${a} and ${b}", "$${a} and $${b}"}, // no interpolation {"no interpolation", "no interpolation"}, // dollar not followed by brace {"$not_interpolation", "$not_interpolation"}, // empty string {"", ""}, // just the pattern {"${foo}", "$${foo}"}, // starts with already-escaped {"$${foo} and ${bar}", "$${foo} and $${bar}"}, } for i, tc := range testCases { name := tc.input if name == "" { name = fmt.Sprintf("case-%d", i) } t.Run(name, func(t *testing.T) { t.Parallel() actual := util.EscapeInterpolationInString(tc.input) assert.Equal(t, tc.expected, actual) }) } } ================================================ FILE: internal/util/lockfile.go ================================================ package util import ( "os" "github.com/gofrs/flock" "github.com/gruntwork-io/terragrunt/internal/errors" ) type Lockfile struct { *flock.Flock } func NewLockfile(filename string) *Lockfile { return &Lockfile{ flock.New(filename), } } func (lockfile *Lockfile) Unlock() error { if lockfile.Flock == nil { return nil } if err := lockfile.Flock.Unlock(); err != nil { return errors.New(err) } if FileExists(lockfile.Path()) { if err := os.Remove(lockfile.Path()); err != nil { return errors.New(err) } } return nil } func (lockfile *Lockfile) TryLock() error { if locked, err := lockfile.Flock.TryLock(); err != nil { return errors.New(err) } else if !locked { return errors.Errorf("unable to lock file %s", lockfile.Path()) } return nil } ================================================ FILE: internal/util/locks.go ================================================ package util import ( "sync" ) // KeyLocks manages a map of locks, each associated with a string key. type KeyLocks struct { locks map[string]*sync.Mutex masterLock sync.Mutex } // NewKeyLocks creates a new instance of KeyLocks. func NewKeyLocks() *KeyLocks { return &KeyLocks{ locks: make(map[string]*sync.Mutex), } } // getOrCreateLock retrieves the lock for the given key, creating it if it doesn't exist. func (kl *KeyLocks) getOrCreateLock(key string) *sync.Mutex { kl.masterLock.Lock() defer kl.masterLock.Unlock() lock, ok := kl.locks[key] if !ok { lock = &sync.Mutex{} kl.locks[key] = lock } return lock } // Lock acquires the lock for the given key. func (kl *KeyLocks) Lock(key string) { lock := kl.getOrCreateLock(key) lock.Lock() } // Unlock releases the lock for the given key. func (kl *KeyLocks) Unlock(key string) { kl.masterLock.Lock() defer kl.masterLock.Unlock() if lock, ok := kl.locks[key]; ok { lock.Unlock() } } ================================================ FILE: internal/util/locks_test.go ================================================ package util_test import ( "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/require" ) // TestKeyLocksBasic verifies basic locking and unlocking behavior. func TestKeyLocksBasic(t *testing.T) { t.Parallel() kl := util.NewKeyLocks() var counter int // Counter to track lock/unlock cycles kl.Lock("key1") counter++ kl.Unlock("key1") counter++ require.Equal(t, 2, counter, "Lock/unlock cycle should be completed") } // TestKeyLocksConcurrentAccess ensures thread-safe access for multiple keys. func TestKeyLocksConcurrentAccess(t *testing.T) { t.Parallel() kl := util.NewKeyLocks() var ( counters [10]int wg sync.WaitGroup ) for i := range 10 { wg.Add(1) go func(key string, idx int) { defer wg.Done() kl.Lock(key) defer kl.Unlock(key) counters[idx]++ counters[idx]++ }("test-key", i) } wg.Wait() for i := range 10 { require.Equal(t, 2, counters[i], "Lock/unlock cycle for each key should be completed") } } // TestKeyLocksUnlockWithoutLock checks for safe behavior when unlocking without locking. func TestKeyLocksUnlockWithoutLock(t *testing.T) { t.Parallel() kl := util.NewKeyLocks() require.NotPanics(t, func() { kl.Unlock("nonexistent_key") }, "Unlocking without locking should not panic") } // TestKeyLocksLockUnlockStressWithSharedKey tests a shared key under high concurrent load. func TestKeyLocksLockUnlockStressWithSharedKey(t *testing.T) { t.Parallel() kl := util.NewKeyLocks() const ( numGoroutines = 100 numOperations = 1000 ) var ( wg sync.WaitGroup counter int ) for range numGoroutines { wg.Add(1) go func() { defer wg.Done() kl.Lock("shared_key") defer kl.Unlock("shared_key") for range numOperations { counter++ counter++ } }() } wg.Wait() require.Equal(t, numGoroutines*numOperations*2, counter, "All lock/unlock cycles should be completed") } ================================================ FILE: internal/util/random.go ================================================ package util import ( "bytes" "math/rand" "time" ) const ( mSecond = 1000 ) // GetRandomTime gets a random time duration between the lower bound and upper bound. // This is useful because some of our automated tests // wound up flooding the AWS API all at once, leading to a "Subscriber limit exceeded" error. // TODO: Some of the more exotic test cases fail, but it's not worth catching them given the intended use of this function. func GetRandomTime(lowerBound, upperBound time.Duration) time.Duration { if lowerBound < 0 { lowerBound = -1 * lowerBound } if upperBound < 0 { upperBound = -1 * upperBound } if lowerBound > upperBound { return upperBound } if lowerBound == upperBound { return lowerBound } lowerBoundMs := lowerBound.Seconds() * mSecond upperBoundMs := upperBound.Seconds() * mSecond lowerBoundMsInt := int(lowerBoundMs) upperBoundMsInt := int(upperBoundMs) randTimeInt := random(lowerBoundMsInt, upperBoundMsInt) return time.Duration(randTimeInt) * time.Millisecond } // Generate a random int between min and max, inclusive func random(min int, max int) int { return rand.Intn(max-min) + min } const Base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const UniqueIDLength = 6 // Should be good for 62^6 = 56+ billion combinations // UniqueID returns a unique (ish) id we can use to name resources so they don't conflict with each other. // Uses base 62 to generate a 6 character string that's unlikely to collide with the handful of // tests we run in parallel. Based on code here: // // http://stackoverflow.com/a/9543797/483528 func UniqueID() string { var out bytes.Buffer for range UniqueIDLength { out.WriteByte(Base62Chars[rand.Intn(len(Base62Chars))]) } return out.String() } ================================================ FILE: internal/util/random_test.go ================================================ package util_test import ( "strconv" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/util" ) func TestGetRandomTime(t *testing.T) { t.Parallel() testCases := []struct { lowerBound time.Duration upperBound time.Duration }{ {1 * time.Second, 10 * time.Second}, {0, 0}, {-1 * time.Second, -3 * time.Second}, {1 * time.Second, 2000000001 * time.Nanosecond}, {1 * time.Millisecond, 10 * time.Millisecond}, // {1 * time.Second, 1000000001 * time.Nanosecond}, // This case fails } // Loop through each test case for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() // Try each test case 100 times to avoid fluke test results for j := range 100 { t.Run(strconv.Itoa(j), func(t *testing.T) { t.Parallel() actual := util.GetRandomTime(tc.lowerBound, tc.upperBound) if tc.lowerBound > 0 && tc.upperBound > 0 { if actual < tc.lowerBound { t.Fatalf("Randomly computed time %v should not be less than lowerBound %v", actual, tc.lowerBound) } if actual > tc.upperBound { t.Fatalf("Randomly computed time %v should not be greater than upperBound %v", actual, tc.upperBound) } } }) } }) } } ================================================ FILE: internal/util/reflect.go ================================================ package util import ( "reflect" "strconv" ) // KindOf returns the kind of the type or Invalid if value is nil. func KindOf(value any) reflect.Kind { valueType := reflect.TypeOf(value) if valueType == nil { return reflect.Invalid } return valueType.Kind() } // MustWalkTerraformOutput is a helper utility to deeply return a value from a terraform output. // // nil will be returned if the path is invalid // // Using an example terraform output: // a = { // b = { // c = "foo" // } // "d" = [ // 1, // 2 // ] // } // // path ["a", "b", "c"] will return "foo" // path ["a", "d", "1"] will return 2 // path ["a", "foo"] will return nil func MustWalkTerraformOutput(value any, path ...string) any { if value == nil { return nil } found := value for _, p := range path { v := reflect.ValueOf(found) switch reflect.TypeOf(found).Kind() { //nolint:exhaustive case reflect.Map: if !v.MapIndex(reflect.ValueOf(p)).IsValid() { return nil } found = v.MapIndex(reflect.ValueOf(p)).Interface() case reflect.Slice, reflect.Array: i, err := strconv.Atoi(p) if err != nil { return nil } if v.Len()-1 < i { return nil } found = v.Index(i).Interface() default: return found } } return found } ================================================ FILE: internal/util/reflect_test.go ================================================ package util_test import ( "math" "reflect" "strconv" "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" ) func TestKindOf(t *testing.T) { t.Parallel() testCases := []struct { value any expected reflect.Kind }{ {1, reflect.Int}, {2.0, reflect.Float64}, {'A', reflect.Int32}, {math.Pi, reflect.Float64}, {true, reflect.Bool}, {nil, reflect.Invalid}, {"Hello World!", reflect.String}, {new(string), reflect.Ptr}, {"", reflect.String}, {any(false), reflect.Bool}, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.KindOf(tc.value).String() assert.Equal(t, tc.expected.String(), actual, "For value %v", tc.value) t.Logf("%v passed", tc.value) }) } } func TestMustWalkTerraformOutput(t *testing.T) { t.Parallel() testCases := []struct { value any expected any path []string }{ { value: map[string]map[string]string{ "a": { "b": "c", }, }, path: []string{"a", "b"}, expected: "c", }, { value: map[string]map[string]string{ "a": { "b": "c", }, }, path: []string{"a", "d"}, expected: nil, }, { value: []string{"a", "b", "c"}, path: []string{"1"}, expected: "b", }, { value: []string{"a", "b", "c"}, path: []string{"10"}, expected: nil, }, } for i, tc := range testCases { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Parallel() actual := util.MustWalkTerraformOutput(tc.value, tc.path...) assert.Equal(t, tc.expected, actual) }) } } ================================================ FILE: internal/util/retry.go ================================================ package util import ( "context" "fmt" "time" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) // DoWithRetry runs the specified action. If it returns a value, return that value. If it returns an error, sleep for // sleepBetweenRetries and try again, up to a maximum of maxRetries retries. If maxRetries is exceeded, return a // MaxRetriesExceeded error. func DoWithRetry(ctx context.Context, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, logger log.Logger, logLevel log.Level, action func(ctx context.Context) error) error { for i := 0; i <= maxRetries; i++ { logger.Logf(logLevel, "%s", actionDescription) err := action(ctx) if err == nil { return nil } var fatalErr FatalError if ok := errors.As(err, &fatalErr); ok { return err } if ctx.Err() != nil { logger.Debugf("%s returned an error: %s.", actionDescription, err.Error()) return errors.New(ctx.Err()) } logger.Errorf("%s returned an error: %s. Retry %d of %d. Sleeping for %s and will try again.", actionDescription, err.Error(), i, maxRetries, sleepBetweenRetries) select { case <-time.After(sleepBetweenRetries): // Try again case <-ctx.Done(): return errors.New(ctx.Err()) } } return MaxRetriesExceeded{Description: actionDescription, MaxRetries: maxRetries} } // MaxRetriesExceeded is an error that occurs when the maximum amount of retries is exceeded. type MaxRetriesExceeded struct { Description string MaxRetries int } func (err MaxRetriesExceeded) Error() string { return fmt.Sprintf("'%s' unsuccessful after %d retries", err.Description, err.MaxRetries) } // FatalError is error interface for cases that should not be retried. type FatalError struct { Underlying error } func (err FatalError) Error() string { return err.Underlying.Error() } func (err FatalError) Unwrap() error { return err.Underlying } ================================================ FILE: internal/util/shell.go ================================================ package util import ( "bytes" "context" "fmt" "os/exec" "strings" "syscall" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/errors" ) // IsCommandExecutable - returns true if a command can be executed without errors. func IsCommandExecutable(ctx context.Context, command string, args ...string) bool { cmd := exec.CommandContext(ctx, command, args...) cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Run(); err != nil { var exitErr *exec.ExitError if ok := errors.As(err, &exitErr); ok { return exitErr.ExitCode() == 0 } return false } return true } type CmdOutput struct { Stdout bytes.Buffer Stderr bytes.Buffer } // GetExitCode returns the exit code of a command. If the error does not // implement errorCode or is not an exec.ExitError // or *errors.MultiError type, the error is returned. func GetExitCode(err error) (int, error) { var exitStatus interface { ExitStatus() (int, error) } if errors.As(err, &exitStatus) { return exitStatus.ExitStatus() } var exitCoder clihelper.ExitCoder if errors.As(err, &exitCoder) { return exitCoder.ExitCode(), nil } var exiterr *exec.ExitError if ok := errors.As(err, &exiterr); ok { status := exiterr.Sys().(syscall.WaitStatus) return status.ExitStatus(), nil } var multiErr *errors.MultiError if ok := errors.As(err, &multiErr); ok { for _, err := range multiErr.WrappedErrors() { exitCode, exitCodeErr := GetExitCode(err) if exitCodeErr == nil { return exitCode, nil } } } return 0, err } // ProcessExecutionError - error returned when a command fails, contains StdOut and StdErr type ProcessExecutionError struct { Err error WorkingDir string RootWorkingDir string Command string Args []string Output CmdOutput LogShowAbsPaths bool DisableSummary bool } func (err ProcessExecutionError) Error() string { //nolint:gocritic commandStr := strings.TrimSpace( strings.Join(append([]string{err.Command}, err.Args...), " "), ) workingDirForLog := RelPathForLog(err.RootWorkingDir, err.WorkingDir, err.LogShowAbsPaths) if err.DisableSummary { return fmt.Sprintf("Failed to execute \"%s\" in %s", commandStr, workingDirForLog, ) } return fmt.Sprintf("Failed to execute \"%s\" in %s\n%s\n%v", commandStr, workingDirForLog, err.Output.Stderr.String(), err.Err, ) } func (err ProcessExecutionError) ExitStatus() (int, error) { //nolint:gocritic return GetExitCode(err.Err) } func (err ProcessExecutionError) Unwrap() error { //nolint:gocritic return err.Err } ================================================ FILE: internal/util/shell_test.go ================================================ package util_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" ) func TestExistingCommand(t *testing.T) { t.Parallel() assert.True(t, util.IsCommandExecutable(t.Context(), "pwd")) } func TestNotExistingCommand(t *testing.T) { t.Parallel() assert.False(t, util.IsCommandExecutable(t.Context(), "not-existing-command", "--version")) } ================================================ FILE: internal/util/sync_writer.go ================================================ package util import ( "io" "sync" ) // SyncWriter wraps an io.Writer with a mutex to make it safe for concurrent use. // This is necessary when multiple goroutines write to the same writer, such as // when running terraform commands in parallel during "run --all" operations. type SyncWriter struct { w io.Writer mu sync.Mutex } // NewSyncWriter returns a new SyncWriter that wraps the given writer. func NewSyncWriter(w io.Writer) *SyncWriter { return &SyncWriter{w: w} } // Write implements the io.Writer interface with synchronization. func (sw *SyncWriter) Write(p []byte) (int, error) { sw.mu.Lock() defer sw.mu.Unlock() return sw.w.Write(p) } ================================================ FILE: internal/util/testdata/fixture-glob-canonical/module-a/terragrunt.hcl ================================================ terraform { source = "test" } ================================================ FILE: internal/util/testdata/fixture-glob-canonical/module-b/module-b-child/main.tf ================================================ ================================================ FILE: internal/util/testdata/fixture-glob-canonical/module-b/module-b-child/terragrunt.hcl ================================================ include { path = find_in_parent_folders("root.hcl") } ================================================ FILE: internal/util/testdata/fixture-glob-canonical/module-b/root.hcl ================================================ # Configure Terragrunt to automatically store tfstate files in an S3 bucket remote_state { backend = "s3" config = { bucket = "bucket" key = "${path_relative_to_include()}/terraform.tfstate" } } terraform { source = "..." } ================================================ FILE: internal/util/testdata/fixture-sanitize-path/env/unit/.terraform-version ================================================ ================================================ FILE: internal/util/trap_writer.go ================================================ package util import ( "io" "github.com/gruntwork-io/terragrunt/internal/errors" ) // TrapWriter intercepts any messages received from the `writer` output. // Used when necessary to filter logs from terraform. type TrapWriter struct { writer io.Writer msgs [][]byte } // NewTrapWriter returns a new TrapWriter instance. func NewTrapWriter(writer io.Writer) *TrapWriter { return &TrapWriter{ writer: writer, } } // Flush flushes intercepted messages to the writer. func (trap *TrapWriter) Flush() error { for _, msg := range trap.msgs { if _, err := trap.writer.Write(msg); err != nil { return errors.New(err) } } return nil } // Write implements `io.Writer` interface. func (trap *TrapWriter) Write(d []byte) (int, error) { msg := make([]byte, len(d)) copy(msg, d) trap.msgs = append(trap.msgs, msg) return len(msg), nil } ================================================ FILE: internal/util/util.go ================================================ // Package util provides utility functions for Terragrunt. package util ================================================ FILE: internal/util/writer_notifier.go ================================================ package util import ( "io" "sync" ) type writerNotifier struct { io.Writer notifyFn func(p []byte) once sync.Once } // WriterNotifier fires `notifyFn` once when the first data comes at `Writer(p []byte)` and forwards data further to the specified `writer`. func WriterNotifier(writer io.Writer, notifyFn func(p []byte)) io.Writer { return &writerNotifier{ Writer: writer, notifyFn: notifyFn, } } func (notifier *writerNotifier) Write(p []byte) (int, error) { if len(p) > 0 { notifier.once.Do(func() { notifier.notifyFn(p) }) } return notifier.Writer.Write(p) } ================================================ FILE: internal/vfs/vfs.go ================================================ // Package vfs provides a virtual filesystem abstraction for testing and production use. // It wraps afero to provide a consistent interface for filesystem operations. package vfs import ( "archive/zip" "bytes" "errors" "fmt" "io" "os" "path/filepath" "slices" "strings" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/spf13/afero" ) // FS is the filesystem interface used throughout the codebase. // It provides an abstraction over real and in-memory filesystems. type FS = afero.Fs // NewOSFS returns a filesystem backed by the real operating system filesystem. func NewOSFS() FS { return afero.NewOsFs() } // NewMemMapFS returns an in-memory filesystem for testing purposes. func NewMemMapFS() FS { return afero.NewMemMapFs() } // FileExists checks if a path exists using the given filesystem. // Returns (true, nil) if the file exists, (false, nil) if it does not exist, // and (false, error) for other errors (e.g., permission denied). func FileExists(fs FS, path string) (bool, error) { _, err := fs.Stat(path) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } // WriteFile writes data to a file on the given filesystem. func WriteFile(fs FS, filename string, data []byte, perm os.FileMode) error { return afero.WriteFile(fs, filename, data, perm) } // ReadFile reads the contents of a file from the given filesystem. func ReadFile(fs FS, filename string) ([]byte, error) { return afero.ReadFile(fs, filename) } // Symlink creates a symbolic link. It uses afero's SymlinkIfPossible // which is supported by both OsFs and MemMapFs. func Symlink(fs FS, oldname, newname string) error { linker, ok := fs.(afero.Linker) if !ok { return &os.LinkError{Op: "symlink", Old: oldname, New: newname, Err: afero.ErrNoSymlink} } return linker.SymlinkIfPossible(oldname, newname) } // ZipDecompressor handles zip archive extraction with configurable limits. type ZipDecompressor struct { // FileSizeLimit limits total decompressed size in bytes. Zero means no limit. FileSizeLimit int64 // FilesLimit limits the number of files. Zero means no limit. FilesLimit int } // ZipDecompressorOption is a functional option for configuring ZipDecompressor. type ZipDecompressorOption func(*ZipDecompressor) // WithFileSizeLimit sets the maximum total decompressed size in bytes. // Zero means no limit. func WithFileSizeLimit(limit int64) ZipDecompressorOption { return func(z *ZipDecompressor) { z.FileSizeLimit = limit } } // WithFilesLimit sets the maximum number of files that can be extracted. // Zero means no limit. func WithFilesLimit(limit int) ZipDecompressorOption { return func(z *ZipDecompressor) { z.FilesLimit = limit } } // NewZipDecompressor creates a new ZipDecompressor with the given options. func NewZipDecompressor(opts ...ZipDecompressorOption) *ZipDecompressor { z := &ZipDecompressor{} for _, opt := range opts { opt(z) } return z } // Unzip extracts a zip archive from src to dst directory on the given filesystem. // The umask parameter is applied to file permissions (use 0 to preserve original permissions). func (z *ZipDecompressor) Unzip(l log.Logger, fs FS, dst, src string, umask os.FileMode) error { file, err := fs.Open(src) if err != nil { return fmt.Errorf("failed to open zip archive %q: %w", src, err) } defer func() { if closeErr := file.Close(); closeErr != nil { l.Warnf("Error closing zip archive %q: %v", src, closeErr) } }() fileInfo, err := file.Stat() if err != nil { return fmt.Errorf("failed to stat zip archive %q: %w", src, err) } size := fileInfo.Size() var readerAt io.ReaderAt if ra, ok := file.(io.ReaderAt); ok { readerAt = ra } else { data, err := io.ReadAll(file) if err != nil { return fmt.Errorf("failed to read zip archive %q: %w", src, err) } readerAt = bytes.NewReader(data) size = int64(len(data)) } zipReader, err := zip.NewReader(readerAt, size) if err != nil { return fmt.Errorf("failed to read zip archive %q: %w", src, err) } if err := fs.MkdirAll(dst, os.ModePerm); err != nil { return fmt.Errorf("failed to create directory %q: %w", dst, err) } if z.FilesLimit > 0 && len(zipReader.File) > z.FilesLimit { return fmt.Errorf( "zip archive contains %d files, exceeds limit of %d", len(zipReader.File), z.FilesLimit, ) } var totalSize int64 for _, zipFile := range zipReader.File { if err := z.extractZipFile(l, fs, dst, zipFile, umask, &totalSize); err != nil { return fmt.Errorf("failed to extract file %q: %w", zipFile.Name, err) } } return nil } // containsDotDot checks if a path contains ".." as a path component. // This is more precise than strings.Contains(name, "..") which would // reject legitimate files like "file..txt". func containsDotDot(v string) bool { if !strings.Contains(v, "..") { return false } return slices.Contains(strings.FieldsFunc(v, func(r rune) bool { return r == '/' || r == '\\' }), "..") } // sanitizeZipPath validates and sanitizes a zip entry path to prevent ZipSlip attacks. func sanitizeZipPath(dst, name string) (string, error) { if containsDotDot(name) { return "", fmt.Errorf("illegal file path in zip: %s", name) } destPath := filepath.Join(dst, filepath.Clean(name)) if !strings.HasPrefix(destPath, filepath.Clean(dst)+string(os.PathSeparator)) { return "", fmt.Errorf("illegal destination path in zip: %s", destPath) } return destPath, nil } // extractZipFile extracts a single file from a zip archive. func (z *ZipDecompressor) extractZipFile(l log.Logger, fs FS, dst string, zipFile *zip.File, umask os.FileMode, totalSize *int64) error { destPath, err := sanitizeZipPath(dst, zipFile.Name) if err != nil { return err } fileInfo := zipFile.FileInfo() if fileInfo.IsDir() { if err := fs.MkdirAll(destPath, applyUmask(fileInfo.Mode(), umask)); err != nil { return fmt.Errorf("failed to create directory %q: %w", destPath, err) } return nil } if fileInfo.Mode()&os.ModeSymlink != 0 { return extractSymlink(l, fs, dst, destPath, zipFile) } return z.extractRegularFile(l, fs, destPath, zipFile, umask, totalSize) } // validateSymlinkTarget validates that a symlink target doesn't escape the destination directory. func validateSymlinkTarget(dst, linkPath, target string) error { // Resolve the target relative to the link's directory absTarget := target if !filepath.IsAbs(target) { absTarget = filepath.Join(filepath.Dir(linkPath), target) } absTarget = filepath.Clean(absTarget) cleanDst := filepath.Clean(dst) // Ensure it stays within dst if !strings.HasPrefix(absTarget, cleanDst+string(os.PathSeparator)) && absTarget != cleanDst { return fmt.Errorf("symlink target escapes destination: %s -> %s", linkPath, target) } return nil } // extractSymlink extracts a symlink from a zip file. func extractSymlink(l log.Logger, fs FS, dst, destPath string, zipFile *zip.File) error { rc, err := zipFile.Open() if err != nil { return fmt.Errorf("failed to open file %q: %w", zipFile.Name, err) } defer func() { if closeErr := rc.Close(); closeErr != nil { l.Warnf("Error closing file %q: %v", zipFile.Name, closeErr) } }() targetBytes, err := io.ReadAll(rc) if err != nil { return fmt.Errorf("failed to read file %q: %w", zipFile.Name, err) } target := string(targetBytes) // Validate symlink target doesn't escape destination if err := validateSymlinkTarget(dst, destPath, target); err != nil { return err } if err := fs.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { return fmt.Errorf("failed to create directory %q: %w", filepath.Dir(destPath), err) } return Symlink(fs, target, destPath) } // extractRegularFile extracts a regular file from a zip file. func (z *ZipDecompressor) extractRegularFile( l log.Logger, fs FS, destPath string, zipFile *zip.File, umask os.FileMode, totalSize *int64, ) error { if err := fs.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { return fmt.Errorf("failed to create directory %q: %w", filepath.Dir(destPath), err) } rc, err := zipFile.Open() if err != nil { return fmt.Errorf("failed to open file %q: %w", zipFile.Name, err) } defer func() { if closeErr := rc.Close(); closeErr != nil { l.Warnf("Error closing file %q: %v", zipFile.Name, closeErr) } }() mode := applyUmask(zipFile.FileInfo().Mode(), umask) outFile, err := fs.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return fmt.Errorf("failed to create file %q: %w", destPath, err) } var reader io.Reader = rc if z.FileSizeLimit > 0 { reader = &limitedReader{ reader: rc, remaining: z.FileSizeLimit - *totalSize, } } written, err := io.Copy(outFile, reader) if err != nil { if closeErr := outFile.Close(); closeErr != nil { l.Warnf("Error closing file %q: %v", destPath, closeErr) } if removeErr := fs.Remove(destPath); removeErr != nil { l.Warnf("Error removing partial file %q: %v", destPath, removeErr) } return fmt.Errorf("failed to copy file %q: %w", zipFile.Name, err) } if err := outFile.Close(); err != nil { l.Warnf("Error closing file %q: %v", destPath, err) } // Update total size for limit tracking if z.FileSizeLimit > 0 { *totalSize += written } return nil } // limitedReader wraps a reader and enforces a size limit. type limitedReader struct { reader io.Reader remaining int64 } func (r *limitedReader) Read(p []byte) (int, error) { if r.remaining <= 0 { return 0, errors.New("decompressed size exceeds limit") } if int64(len(p)) > r.remaining { p = p[:r.remaining] } n, err := r.reader.Read(p) r.remaining -= int64(n) return n, err } // applyUmask applies a umask to a file mode. func applyUmask(mode, umask os.FileMode) os.FileMode { return mode &^ umask } ================================================ FILE: internal/vfs/vfs_test.go ================================================ package vfs_test import ( "archive/zip" "bytes" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewOSFS(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() assert.NotNil(t, fs) _, ok := fs.(*afero.OsFs) assert.True(t, ok, "expected *afero.OsFs type") } func TestNewMemMapFS(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() assert.NotNil(t, fs) _, ok := fs.(*afero.MemMapFs) assert.True(t, ok, "expected *afero.MemMapFs type") } func TestFileExists(t *testing.T) { t.Parallel() testCases := []struct { name string setup func(fs vfs.FS) path string expected bool }{ { name: "file exists", setup: func(fs vfs.FS) { require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0644)) }, path: "/test.txt", expected: true, }, { name: "file does not exist", setup: func(fs vfs.FS) {}, path: "/nonexistent.txt", expected: false, }, { name: "directory exists", setup: func(fs vfs.FS) { require.NoError(t, fs.MkdirAll("/testdir", 0755)) }, path: "/testdir", expected: true, }, { name: "parent does not exist", setup: func(fs vfs.FS) {}, path: "/nonexistent/file.txt", expected: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() tc.setup(fs) exists, err := vfs.FileExists(fs, tc.path) require.NoError(t, err) assert.Equal(t, tc.expected, exists) }) } } func TestWriteFile(t *testing.T) { t.Parallel() testCases := []struct { name string filename string data []byte perm os.FileMode }{ { name: "write simple file", filename: "/test.txt", data: []byte("hello world"), perm: 0644, }, { name: "write with restricted permissions", filename: "/restricted.txt", data: []byte("secret"), perm: 0600, }, { name: "write to nested directory", filename: "/nested/path/file.txt", data: []byte("nested content"), perm: 0644, }, { name: "write empty file", filename: "/empty.txt", data: []byte{}, perm: 0644, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() err := vfs.WriteFile(fs, tc.filename, tc.data, tc.perm) require.NoError(t, err) exists, err := vfs.FileExists(fs, tc.filename) require.NoError(t, err) assert.True(t, exists) }) } } func TestReadFile(t *testing.T) { t.Parallel() t.Run("read existing file", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() expected := []byte("test content") require.NoError(t, vfs.WriteFile(fs, "/test.txt", expected, 0644)) data, err := vfs.ReadFile(fs, "/test.txt") require.NoError(t, err) assert.Equal(t, expected, data) }) t.Run("read non-existent file", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() _, err := vfs.ReadFile(fs, "/nonexistent.txt") require.Error(t, err) }) } func TestSymlink(t *testing.T) { t.Parallel() t.Run("create valid symlink", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() targetPath := filepath.Join(tempDir, "target.txt") linkPath := filepath.Join(tempDir, "link.txt") require.NoError(t, vfs.WriteFile(fs, targetPath, []byte("target content"), 0644)) err := vfs.Symlink(fs, targetPath, linkPath) require.NoError(t, err) data, err := vfs.ReadFile(fs, linkPath) require.NoError(t, err) assert.Equal(t, []byte("target content"), data) }) t.Run("symlink to non-existent target", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() linkPath := filepath.Join(tempDir, "dangling_link.txt") err := vfs.Symlink(fs, "/nonexistent/target", linkPath) require.NoError(t, err) }) t.Run("filesystem without symlink support returns LinkError", func(t *testing.T) { t.Parallel() fs := afero.NewReadOnlyFs(vfs.NewMemMapFS()) err := vfs.Symlink(fs, "target", "link") require.Error(t, err) var linkErr *os.LinkError assert.ErrorAs(t, err, &linkErr) }) } func TestUnzip(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("extract single file", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file.txt": []byte("file content"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/file.txt") require.NoError(t, err) assert.Equal(t, []byte("file content"), data) }) t.Run("extract archive with directories", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchiveWithDirs(t, map[string][]byte{ "dir/": nil, "dir/file.txt": []byte("nested file"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) exists, err := vfs.FileExists(fs, "/dst/dir") require.NoError(t, err) assert.True(t, exists) data, err := vfs.ReadFile(fs, "/dst/dir/file.txt") require.NoError(t, err) assert.Equal(t, []byte("nested file"), data) }) t.Run("extract archive with nested structure", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "a/b/c/deep.txt": []byte("deep content"), "root.txt": []byte("root content"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/a/b/c/deep.txt") require.NoError(t, err) assert.Equal(t, []byte("deep content"), data) data, err = vfs.ReadFile(fs, "/dst/root.txt") require.NoError(t, err) assert.Equal(t, []byte("root content"), data) }) t.Run("extract archive with multiple files", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file1.txt": []byte("content1"), "file2.txt": []byte("content2"), "file3.txt": []byte("content3"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) for i := 1; i <= 3; i++ { data, err := vfs.ReadFile(fs, "/dst/file"+string(rune('0'+i))+".txt") require.NoError(t, err) assert.Equal(t, []byte("content"+string(rune('0'+i))), data) } }) t.Run("zipslip prevention - path with dotdot", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchiveUnsafe(t, map[string][]byte{ "../escaped.txt": []byte("malicious"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "illegal file path") }) t.Run("zipslip prevention - nested dotdot", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchiveUnsafe(t, map[string][]byte{ "foo/../../escaped.txt": []byte("malicious"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "illegal file path") }) t.Run("permissions preserved with umask 0", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") zipData := createZipArchiveWithMode(t, "executable.sh", []byte("#!/bin/bash"), 0755) require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.NoError(t, err) info, err := fs.Stat(filepath.Join(dstPath, "executable.sh")) require.NoError(t, err) assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) }) t.Run("permissions with umask applied", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") zipData := createZipArchiveWithMode(t, "file.txt", []byte("content"), 0666) require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0022) require.NoError(t, err) info, err := fs.Stat(filepath.Join(dstPath, "file.txt")) require.NoError(t, err) assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) }) t.Run("non-existent source file", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/nonexistent.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "failed to open zip archive") }) t.Run("invalid archive", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() require.NoError(t, vfs.WriteFile(fs, "/invalid.zip", []byte("not a zip file"), 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/invalid.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "failed to read zip archive") }) t.Run("extract to existing directory", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() require.NoError(t, fs.MkdirAll("/dst", 0755)) zipData := createZipArchive(t, map[string][]byte{ "new.txt": []byte("new content"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/new.txt") require.NoError(t, err) assert.Equal(t, []byte("new content"), data) }) } func TestUnzipWithSymlinks(t *testing.T) { t.Parallel() l := logger.CreateLogger() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") zipData := createZipArchiveWithSymlink(t, "target.txt", []byte("target content"), "link.txt", "target.txt") require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.NoError(t, err) targetData, err := vfs.ReadFile(fs, filepath.Join(dstPath, "target.txt")) require.NoError(t, err) assert.Equal(t, []byte("target content"), targetData) linkData, err := vfs.ReadFile(fs, filepath.Join(dstPath, "link.txt")) require.NoError(t, err) assert.Equal(t, []byte("target content"), linkData) } // createZipArchive creates a zip archive in memory with the given files. func createZipArchive(t *testing.T, files map[string][]byte) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) for name, content := range files { f, err := w.Create(name) require.NoError(t, err) _, err = f.Write(content) require.NoError(t, err) } require.NoError(t, w.Close()) return buf.Bytes() } // createZipArchiveWithDirs creates a zip archive that includes directory entries. func createZipArchiveWithDirs(t *testing.T, files map[string][]byte) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) for name, content := range files { if content == nil { _, err := w.Create(name) require.NoError(t, err) continue } f, err := w.Create(name) require.NoError(t, err) _, err = f.Write(content) require.NoError(t, err) } require.NoError(t, w.Close()) return buf.Bytes() } // createZipArchiveUnsafe creates a zip archive with potentially malicious paths (for testing ZipSlip). func createZipArchiveUnsafe(t *testing.T, files map[string][]byte) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) for name, content := range files { header := &zip.FileHeader{ Name: name, Method: zip.Deflate, } f, err := w.CreateHeader(header) require.NoError(t, err) _, err = f.Write(content) require.NoError(t, err) } require.NoError(t, w.Close()) return buf.Bytes() } // createZipArchiveWithMode creates a zip archive with a single file with specific permissions. func createZipArchiveWithMode(t *testing.T, name string, content []byte, mode os.FileMode) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) header := &zip.FileHeader{ Name: name, Method: zip.Deflate, } header.SetMode(mode) f, err := w.CreateHeader(header) require.NoError(t, err) _, err = f.Write(content) require.NoError(t, err) require.NoError(t, w.Close()) return buf.Bytes() } // createZipArchiveWithSymlink creates a zip archive with a regular file and a symlink to it. func createZipArchiveWithSymlink(t *testing.T, targetName string, targetContent []byte, linkName, linkTarget string) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) targetHeader := &zip.FileHeader{ Name: targetName, Method: zip.Deflate, } targetHeader.SetMode(0644) f, err := w.CreateHeader(targetHeader) require.NoError(t, err) _, err = f.Write(targetContent) require.NoError(t, err) linkHeader := &zip.FileHeader{ Name: linkName, Method: zip.Deflate, } linkHeader.SetMode(os.ModeSymlink | 0777) linkFile, err := w.CreateHeader(linkHeader) require.NoError(t, err) _, err = linkFile.Write([]byte(linkTarget)) require.NoError(t, err) require.NoError(t, w.Close()) return buf.Bytes() } func TestContainsDotDot(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("allows file with double dots in name", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file..txt": []byte("content with dots"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/file..txt") require.NoError(t, err) assert.Equal(t, []byte("content with dots"), data) }) t.Run("allows file with multiple dots", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "my..file..name.txt": []byte("multiple dots"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/my..file..name.txt") require.NoError(t, err) assert.Equal(t, []byte("multiple dots"), data) }) t.Run("blocks path with dotdot component", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchiveUnsafe(t, map[string][]byte{ "../evil.txt": []byte("malicious"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "illegal file path") }) t.Run("blocks nested dotdot path", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchiveUnsafe(t, map[string][]byte{ "subdir/../../../evil.txt": []byte("malicious"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "illegal file path") }) } func TestUnzipFilesLimit(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("allows extraction within file limit", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file1.txt": []byte("content1"), "file2.txt": []byte("content2"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor(vfs.WithFilesLimit(5)).Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) exists, err := vfs.FileExists(fs, "/dst/file1.txt") require.NoError(t, err) assert.True(t, exists) }) t.Run("rejects extraction exceeding file limit", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file1.txt": []byte("content1"), "file2.txt": []byte("content2"), "file3.txt": []byte("content3"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor(vfs.WithFilesLimit(2)).Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds limit") }) t.Run("no limit when FilesLimit is zero", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file1.txt": []byte("content1"), "file2.txt": []byte("content2"), "file3.txt": []byte("content3"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) }) } func TestUnzipFileSizeLimit(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("allows extraction within size limit", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "small.txt": []byte("small content"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor(vfs.WithFileSizeLimit(1000)).Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) data, err := vfs.ReadFile(fs, "/dst/small.txt") require.NoError(t, err) assert.Equal(t, []byte("small content"), data) }) t.Run("rejects extraction exceeding size limit", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() // Create content that exceeds 10 bytes zipData := createZipArchive(t, map[string][]byte{ "large.txt": []byte("this content is definitely more than 10 bytes"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor(vfs.WithFileSizeLimit(10)).Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds limit") }) t.Run("cumulative size limit across files", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() // Each file is 10 bytes, total 30 bytes zipData := createZipArchive(t, map[string][]byte{ "file1.txt": []byte("0123456789"), "file2.txt": []byte("0123456789"), "file3.txt": []byte("0123456789"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor(vfs.WithFileSizeLimit(25)).Unzip(l, fs, "/dst", "/archive.zip", 0) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds limit") }) t.Run("no limit when FileSizeLimit is zero", func(t *testing.T) { t.Parallel() fs := vfs.NewMemMapFS() zipData := createZipArchive(t, map[string][]byte{ "file.txt": []byte("content that would exceed any small limit"), }) require.NoError(t, vfs.WriteFile(fs, "/archive.zip", zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, "/dst", "/archive.zip", 0) require.NoError(t, err) }) } func TestUnzipSymlinkEscape(t *testing.T) { t.Parallel() l := logger.CreateLogger() t.Run("allows symlink to file within destination", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") zipData := createZipArchiveWithSymlink(t, "target.txt", []byte("target content"), "link.txt", "target.txt") require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.NoError(t, err) linkData, err := vfs.ReadFile(fs, filepath.Join(dstPath, "link.txt")) require.NoError(t, err) assert.Equal(t, []byte("target content"), linkData) }) t.Run("rejects symlink escaping destination with absolute path", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") // Create symlink pointing to absolute path outside destination zipData := createZipArchiveWithSymlink(t, "target.txt", []byte("target content"), "evil_link.txt", "/etc/passwd") require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.Error(t, err) assert.Contains(t, err.Error(), "symlink target escapes destination") }) t.Run("rejects symlink escaping destination with relative path", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") // Create symlink pointing outside destination with .. zipData := createZipArchiveWithSymlink(t, "target.txt", []byte("target content"), "evil_link.txt", "../../../etc/passwd") require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.Error(t, err) assert.Contains(t, err.Error(), "symlink target escapes destination") }) t.Run("allows symlink within nested directory", func(t *testing.T) { t.Parallel() fs := vfs.NewOSFS() tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "archive.zip") dstPath := filepath.Join(tempDir, "dst") // Create symlink in subdirectory pointing to file in same directory zipData := createZipArchiveWithNestedSymlink(t) require.NoError(t, vfs.WriteFile(fs, zipPath, zipData, 0644)) err := vfs.NewZipDecompressor().Unzip(l, fs, dstPath, zipPath, 0) require.NoError(t, err) }) } // createZipArchiveWithNestedSymlink creates a zip with a symlink in a subdirectory. func createZipArchiveWithNestedSymlink(t *testing.T) []byte { t.Helper() var buf bytes.Buffer w := zip.NewWriter(&buf) // Create target file in subdir targetHeader := &zip.FileHeader{ Name: "subdir/target.txt", Method: zip.Deflate, } targetHeader.SetMode(0644) f, err := w.CreateHeader(targetHeader) require.NoError(t, err) _, err = f.Write([]byte("target content")) require.NoError(t, err) // Create symlink in same subdir pointing to target linkHeader := &zip.FileHeader{ Name: "subdir/link.txt", Method: zip.Deflate, } linkHeader.SetMode(os.ModeSymlink | 0777) linkFile, err := w.CreateHeader(linkHeader) require.NoError(t, err) _, err = linkFile.Write([]byte("target.txt")) require.NoError(t, err) require.NoError(t, w.Close()) return buf.Bytes() } ================================================ FILE: internal/view/diagnostic/diagnostic.go ================================================ // Package diagnostic provides a way to represent diagnostics in a way // that can be easily marshalled to JSON. package diagnostic import ( "github.com/hashicorp/hcl/v2" ) type Diagnostics []*Diagnostic func (diags *Diagnostics) Contains(find *Diagnostic) bool { for _, diag := range *diags { if find.Range != nil && find.Range.String() == diag.Range.String() { return true } } return false } type Diagnostic struct { Range *Range `json:"range,omitempty"` Snippet *Snippet `json:"snippet,omitempty"` Summary string `json:"summary"` Detail string `json:"detail"` Severity DiagnosticSeverity `json:"severity"` } func NewDiagnostic(file *hcl.File, hclDiag *hcl.Diagnostic) *Diagnostic { diag := &Diagnostic{ Severity: DiagnosticSeverity(hclDiag.Severity), Summary: hclDiag.Summary, Detail: hclDiag.Detail, } if hclDiag.Subject == nil { return diag } highlightRange := *hclDiag.Subject if highlightRange.Empty() { highlightRange.End.Byte++ highlightRange.End.Column++ } diag.Snippet = NewSnippet(file, hclDiag, highlightRange) diag.Range = &Range{ Filename: highlightRange.Filename, Start: Pos{ Line: highlightRange.Start.Line, Column: highlightRange.Start.Column, Byte: highlightRange.Start.Byte, }, End: Pos{ Line: highlightRange.End.Line, Column: highlightRange.End.Column, Byte: highlightRange.End.Byte, }, } return diag } ================================================ FILE: internal/view/diagnostic/expression_value.go ================================================ package diagnostic import ( "bytes" "fmt" "sort" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" ) const ( // Sensitive indicates that this value is marked as sensitive in the context of Terraform. Sensitive = valueMark("Sensitive") ) // valueMarks allow creating strictly typed values for use as cty.Value marks. type valueMark string func (m valueMark) GoString() string { return "marks." + string(m) } // ExpressionValue represents an HCL traversal string and a statement about its value while the expression was evaluated. type ExpressionValue struct { Traversal string `json:"traversal"` Statement string `json:"statement"` } func DescribeExpressionValues(hclDiag *hcl.Diagnostic) []ExpressionValue { var ( expr = hclDiag.Expression ctx = hclDiag.EvalContext vars = expr.Variables() values = make([]ExpressionValue, 0, len(vars)) seen = make(map[string]struct{}, len(vars)) includeUnknown = DiagnosticCausedByUnknown(hclDiag) includeSensitive = DiagnosticCausedBySensitive(hclDiag) ) Traversals: for _, traversal := range vars { for len(traversal) > 1 { val, diags := traversal.TraverseAbs(ctx) if diags.HasErrors() { traversal = traversal[:len(traversal)-1] continue } traversalStr := traversalStr(traversal) if _, exists := seen[traversalStr]; exists { continue Traversals } value := ExpressionValue{ Traversal: traversalStr, } switch { case val.HasMark(Sensitive): if !includeSensitive { continue Traversals } value.Statement = "has a sensitive value" case !val.IsKnown(): if ty := val.Type(); ty != cty.DynamicPseudoType { if includeUnknown { value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) } else { value.Statement = "is a " + ty.FriendlyName() } } else { if !includeUnknown { continue Traversals } value.Statement = "will be known only after apply" } default: value.Statement = "is " + valueStr(val) } values = append(values, value) seen[traversalStr] = struct{}{} } } sort.Slice(values, func(i, j int) bool { return values[i].Traversal < values[j].Traversal }) return values } func traversalStr(traversal hcl.Traversal) string { var buf bytes.Buffer for _, step := range traversal { switch tStep := step.(type) { case hcl.TraverseRoot: buf.WriteString(tStep.Name) case hcl.TraverseAttr: buf.WriteByte('.') buf.WriteString(tStep.Name) case hcl.TraverseIndex: buf.WriteByte('[') if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() { buf.WriteString(valueStr(tStep.Key)) } else { // We'll just use a placeholder for more complex values, since otherwise our result could grow ridiculously long. buf.WriteString("...") } buf.WriteByte(']') } } return buf.String() } func valueStr(val cty.Value) string { if val.HasMark(Sensitive) { return "(sensitive value)" } ty := val.Type() switch { case val.IsNull(): return "null" case !val.IsKnown(): return "(not yet known)" case ty == cty.Bool: if val.True() { return "true" } return "false" case ty == cty.Number: bf := val.AsBigFloat() prec := 10 return bf.Text('g', prec) case ty == cty.String: return fmt.Sprintf("%q", val.AsString()) case ty.IsCollectionType() || ty.IsTupleType(): l := val.LengthInt() switch l { case 0: return "empty " + ty.FriendlyName() case 1: return ty.FriendlyName() + " with 1 element" default: return fmt.Sprintf("%s with %d elements", ty.FriendlyName(), l) } case ty.IsObjectType(): atys := ty.AttributeTypes() l := len(atys) switch l { case 0: return "object with no attributes" case 1: var name string for k := range atys { name = k } return fmt.Sprintf("object with 1 attribute %q", name) default: return fmt.Sprintf("object with %d attributes", l) } default: return ty.FriendlyName() } } ================================================ FILE: internal/view/diagnostic/extra.go ================================================ package diagnostic import "github.com/hashicorp/hcl/v2" func ExtraInfo[T any](diag *hcl.Diagnostic) T { extra := diag.Extra if ret, ok := extra.(T); ok { return ret } // If "extra" doesn't implement T directly then we'll delegate to our ExtraInfoNext helper to try iteratively unwrapping it. return ExtraInfoNext[T](extra) } // ExtraInfoNext takes a value previously returned by ExtraInfo and attempts to find an implementation of interface T wrapped inside of it. The return value meaning is the same as for ExtraInfo. func ExtraInfoNext[T any](previous any) T { // As long as T is an interface type as documented, zero will always be a nil interface value for us to return in the non-matching case. var zero T unwrapper, ok := previous.(DiagnosticExtraUnwrapper) // If the given value isn't unwrappable then it can't possibly have any other info nested inside of it. if !ok { return zero } extra := unwrapper.UnwrapDiagnosticExtra() // Keep unwrapping until we either find the interface to look for or we run out of layers of unwrapper. for { if ret, ok := extra.(T); ok { return ret } if unwrapper, ok := extra.(DiagnosticExtraUnwrapper); ok { extra = unwrapper.UnwrapDiagnosticExtra() } else { return zero } } } // DiagnosticExtraUnwrapper is an interface implemented by values in the Extra field of Diagnostic when they are wrapping another "Extra" value that was generated downstream. type DiagnosticExtraUnwrapper interface { UnwrapDiagnosticExtra() any } // DiagnosticExtraBecauseUnknown is an interface implemented by values in the Extra field of Diagnostic when the diagnostic is potentially caused by the presence of unknown values in an expression evaluation. type DiagnosticExtraBecauseUnknown interface { DiagnosticCausedByUnknown() bool } // DiagnosticCausedByUnknown returns true if the given diagnostic has an indication that it was caused by the presence of unknown values during an expression evaluation. func DiagnosticCausedByUnknown(diag *hcl.Diagnostic) bool { maybe := ExtraInfo[DiagnosticExtraBecauseUnknown](diag) if maybe == nil { return false } return maybe.DiagnosticCausedByUnknown() } // DiagnosticExtraBecauseSensitive is an interface implemented by values in the Extra field of Diagnostic when the diagnostic is potentially caused by the presence of sensitive values in an expression evaluation. type DiagnosticExtraBecauseSensitive interface { DiagnosticCausedBySensitive() bool } // DiagnosticCausedBySensitive returns true if the given diagnostic has an/ indication that it was caused by the presence of sensitive values during an expression evaluation. func DiagnosticCausedBySensitive(diag *hcl.Diagnostic) bool { maybe := ExtraInfo[DiagnosticExtraBecauseSensitive](diag) if maybe == nil { return false } return maybe.DiagnosticCausedBySensitive() } ================================================ FILE: internal/view/diagnostic/function.go ================================================ package diagnostic import ( "encoding/json" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ) // FunctionParam represents a single parameter to a function, as represented by type Function. type FunctionParam struct { // Name is a name for the function which is used primarily for documentation purposes. Name string `json:"name"` Description string `json:"description,omitempty"` DescriptionKind string `json:"description_kind,omitempty"` // Type is a type constraint which is a static approximation of the possibly-dynamic type of the parameter Type json.RawMessage `json:"type"` } func DescribeFunctionParam(p *function.Parameter) FunctionParam { ret := FunctionParam{ Name: p.Name, } if raw, err := p.Type.MarshalJSON(); err != nil { // Treat any errors as if the function is dynamically typed because it would be weird to get here. ret.Type = json.RawMessage(`"dynamic"`) } else { ret.Type = raw } return ret } // Function is a description of the JSON representation of the signature of a function callable from the Terraform language. type Function struct { VariadicParam *FunctionParam `json:"variadic_param,omitempty"` // Name is the leaf name of the function, without any namespace prefix. Name string `json:"name"` Description string `json:"description,omitempty"` DescriptionKind string `json:"description_kind,omitempty"` Params []FunctionParam `json:"params"` // ReturnType is type constraint which is a static approximation of the possibly-dynamic return type of the function. ReturnType json.RawMessage `json:"return_type"` } // DescribeFunction returns a description of the signature of the given cty function, as a pointer to this package's serializable type Function. func DescribeFunction(name string, f function.Function) *Function { ret := &Function{ Name: name, } params := f.Params() ret.Params = make([]FunctionParam, len(params)) typeCheckArgs := make([]cty.Type, len(params), len(params)+1) for i, param := range params { ret.Params[i] = DescribeFunctionParam(¶m) typeCheckArgs[i] = param.Type } if varParam := f.VarParam(); varParam != nil { descParam := DescribeFunctionParam(varParam) ret.VariadicParam = &descParam typeCheckArgs = append(typeCheckArgs, varParam.Type) } retType, err := f.ReturnType(typeCheckArgs) if err != nil { retType = cty.DynamicPseudoType } if raw, err := retType.MarshalJSON(); err != nil { // Treat any errors as if the function is dynamically typed because it would be weird to get here. ret.ReturnType = json.RawMessage(`"dynamic"`) } else { ret.ReturnType = raw } return ret } // FunctionCall represents a function call whose information is being included as part of a diagnostic snippet. type FunctionCall struct { // Signature is a description of the signature of the function that was/ called, if any.: Signature *Function `json:"signature,omitempty"` // CalledAs is the full name that was used to call this function, potentially including namespace prefixes if the function does not belong to the default function namespace. CalledAs string `json:"called_as"` } func DescribeFunctionCall(hclDiag *hcl.Diagnostic) *FunctionCall { callInfo := ExtraInfo[hclsyntax.FunctionCallDiagExtra](hclDiag) if callInfo == nil || callInfo.CalledFunctionName() == "" { return nil } calledAs := callInfo.CalledFunctionName() baseName := calledAs if idx := strings.LastIndex(baseName, "::"); idx >= 0 { baseName = baseName[idx+2:] } var signature *Function if f, ok := hclDiag.EvalContext.Functions[calledAs]; ok { signature = DescribeFunction(baseName, f) } return &FunctionCall{ CalledAs: calledAs, Signature: signature, } } ================================================ FILE: internal/view/diagnostic/range.go ================================================ package diagnostic import "fmt" // Pos represents a position in the source code. type Pos struct { // Line is a one-based count for the line in the indicated file. Line int `json:"line"` // Column is a one-based count of Unicode characters from the start of the line. Column int `json:"column"` // Byte is a zero-based offset into the indicated file. Byte int `json:"byte"` } // Range represents the filename and position of the diagnostic subject. type Range struct { Filename string `json:"filename"` Start Pos `json:"start"` End Pos `json:"end"` } func (rng Range) String() string { if rng.Start.Line == rng.End.Line { return fmt.Sprintf( "%s:%d,%d-%d", rng.Filename, rng.Start.Line, rng.Start.Column, rng.End.Column, ) } else { return fmt.Sprintf( "%s:%d,%d-%d,%d", rng.Filename, rng.Start.Line, rng.Start.Column, rng.End.Line, rng.End.Column, ) } } ================================================ FILE: internal/view/diagnostic/servity.go ================================================ package diagnostic import ( "fmt" "strings" "github.com/hashicorp/hcl/v2" ) const ( DiagnosticSeverityUnknown = "unknown" DiagnosticSeverityError = "error" DiagnosticSeverityWarning = "warning" ) type DiagnosticSeverity hcl.DiagnosticSeverity func (severity DiagnosticSeverity) String() string { // TODO: Remove lint suppression switch hcl.DiagnosticSeverity(severity) { //nolint:exhaustive case hcl.DiagError: return DiagnosticSeverityError case hcl.DiagWarning: return DiagnosticSeverityWarning default: return DiagnosticSeverityUnknown } } func (severity DiagnosticSeverity) MarshalJSON() ([]byte, error) { return fmt.Appendf(nil, `"%s"`, severity.String()), nil } func (severity *DiagnosticSeverity) UnmarshalJSON(val []byte) error { switch strings.Trim(string(val), `"`) { case DiagnosticSeverityError: *severity = DiagnosticSeverity(hcl.DiagError) case DiagnosticSeverityWarning: *severity = DiagnosticSeverity(hcl.DiagWarning) } return nil } ================================================ FILE: internal/view/diagnostic/snippet.go ================================================ package diagnostic import ( "bufio" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcled" ) // Snippet represents source code information about the diagnostic. type Snippet struct { // FunctionCall is information about a function call whose failure is being reported by this diagnostic, if any. FunctionCall *FunctionCall `json:"function_call,omitempty"` // Context is derived from HCL's hcled.ContextString output. This gives a high-level summary of the root context of the diagnostic. Context string `json:"context"` // Code is a possibly-multi-line string of OpenTofu/Terraform configuration, which includes both the diagnostic source and any relevant context as defined by the diagnostic. Code string `json:"code"` // Values is a sorted slice of expression values which may be useful in understanding the source of an error in a complex expression. Values []ExpressionValue `json:"values"` // StartLine is the line number in the source file for the first line of the snippet code block. StartLine int `json:"start_line"` // HighlightStartOffset is the character offset into Code at which the diagnostic source range starts, which ought to be highlighted as such by the consumer of this data. HighlightStartOffset int `json:"highlight_start_offset"` // HighlightEndOffset is the character offset into Code at which the diagnostic source range ends. HighlightEndOffset int `json:"highlight_end_offset"` } func NewSnippet(file *hcl.File, hclDiag *hcl.Diagnostic, highlightRange hcl.Range) *Snippet { snipRange := *hclDiag.Subject if hclDiag.Context != nil { // Show enough of the source code to include both the subject and context ranges, which overlap in all reasonable situations. snipRange = hcl.RangeOver(snipRange, *hclDiag.Context) } if snipRange.Empty() { snipRange.End.Byte++ snipRange.End.Column++ } snippet := &Snippet{ StartLine: hclDiag.Subject.Start.Line, } if file != nil && file.Bytes != nil { snippet.Context = hcled.ContextString(file, hclDiag.Subject.Start.Byte-1) var ( codeStartByte int code strings.Builder ) sc := hcl.NewRangeScanner(file.Bytes, hclDiag.Subject.Filename, bufio.ScanLines) for sc.Scan() { lineRange := sc.Range() if lineRange.Overlaps(snipRange) { if codeStartByte == 0 && code.Len() == 0 { codeStartByte = lineRange.Start.Byte } code.Write(lineRange.SliceBytes(file.Bytes)) code.WriteRune('\n') } } codeStr := strings.TrimSuffix(code.String(), "\n") snippet.Code = codeStr start := highlightRange.Start.Byte - codeStartByte end := start + (highlightRange.End.Byte - highlightRange.Start.Byte) if start < 0 { start = 0 } else if start > len(codeStr) { start = len(codeStr) } if end < 0 { end = 0 } else if end > len(codeStr) { end = len(codeStr) } snippet.HighlightStartOffset = start snippet.HighlightEndOffset = end } if hclDiag.Expression == nil || hclDiag.EvalContext == nil { return snippet } snippet.Values = DescribeExpressionValues(hclDiag) snippet.FunctionCall = DescribeFunctionCall(hclDiag) return snippet } ================================================ FILE: internal/view/human_render.go ================================================ package view import ( "bufio" "bytes" "fmt" "os" "sort" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/view/diagnostic" "github.com/hashicorp/hcl/v2" "github.com/mitchellh/colorstring" "github.com/mitchellh/go-wordwrap" "golang.org/x/term" ) const defaultWidth = 78 type HumanRender struct { colorize *colorstring.Colorize width int } func NewHumanRender(disableColor bool) Render { disableColor = disableColor || !term.IsTerminal(int(os.Stderr.Fd())) width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { width = defaultWidth } return &HumanRender{ colorize: &colorstring.Colorize{ Colors: colorstring.DefaultColors, Disable: disableColor, Reset: true, }, width: width, } } func (render *HumanRender) ShowConfigPath(filenames []string) (string, error) { var buf bytes.Buffer for _, filename := range filenames { buf.WriteString(filename) buf.WriteByte('\n') } return buf.String(), nil } func (render *HumanRender) Diagnostics(diags diagnostic.Diagnostics) (string, error) { var buf bytes.Buffer for _, diag := range diags { str, err := render.Diagnostic(diag) if err != nil { return "", err } if str != "" { buf.WriteString(str) buf.WriteByte('\n') } } return buf.String(), nil } // Diagnostic formats a single diagnostic message. func (render *HumanRender) Diagnostic(diag *diagnostic.Diagnostic) (string, error) { var buf bytes.Buffer // these leftRule* variables are markers for the beginning of the lines // containing the diagnostic that are intended to help sighted users // better understand the information hierarchy when diagnostics appear // alongside other information or alongside other diagnostics. // // Without this, it seems (based on folks sharing incomplete messages when // asking questions, or including extra content that's not part of the // diagnostic) that some readers have trouble easily identifying which // text belongs to the diagnostic and which does not. var ( leftRuleLine, leftRuleStart, leftRuleEnd string leftRuleWidth int // in visual character cells ) // TODO: Remove lint suppression switch hcl.DiagnosticSeverity(diag.Severity) { //nolint:exhaustive case hcl.DiagError: buf.WriteString(render.colorize.Color("[bold][red]Error: [reset]")) leftRuleLine = render.colorize.Color("[red]│[reset] ") leftRuleStart = render.colorize.Color("[red]╷[reset]") leftRuleEnd = render.colorize.Color("[red]╵[reset]") leftRuleWidth = 2 case hcl.DiagWarning: buf.WriteString(render.colorize.Color("[bold][yellow]Warning: [reset]")) leftRuleLine = render.colorize.Color("[yellow]│[reset] ") leftRuleStart = render.colorize.Color("[yellow]╷[reset]") leftRuleEnd = render.colorize.Color("[yellow]╵[reset]") leftRuleWidth = 2 default: // Clear out any coloring that might be applied by Terraform's UI helper, // so our result is not context-sensitive. buf.WriteString(render.colorize.Color("\n[reset]")) } // We don't wrap the summary, since we expect it to be terse, and since // this is where we put the text of a native Go error it may not always // be pure text that lends itself well to word-wrapping. if _, err := fmt.Fprintf(&buf, render.colorize.Color("[bold]%s[reset]\n\n"), diag.Summary); err != nil { return "", errors.New(err) } sourceSnippets, err := render.SourceSnippets(diag) if err != nil { return "", err } buf.WriteString(sourceSnippets) if diag.Detail != "" { paraWidth := render.width - leftRuleWidth - 1 // leave room for the left rule if paraWidth > 0 { lines := strings.SplitSeq(diag.Detail, "\n") for line := range lines { if !strings.HasPrefix(line, " ") { line = wordwrap.WrapString(line, uint(paraWidth)) } if _, err := fmt.Fprintf(&buf, "%s\n", line); err != nil { return "", errors.New(err) } } } else { if _, err := fmt.Fprintf(&buf, "%s\n", diag.Detail); err != nil { return "", errors.New(err) } } } // Before we return, we'll finally add the left rule prefixes to each // line so that the overall message is visually delimited from what's // around it. We'll do that by scanning over what we already generated // and adding the prefix for each line. var ruleBuf strings.Builder sc := bufio.NewScanner(&buf) ruleBuf.WriteString(leftRuleStart) ruleBuf.WriteByte('\n') for sc.Scan() { prefix := leftRuleLine line := sc.Text() if line == "" { // Don't print the space after the line if there would be nothing // after it anyway. prefix = strings.TrimSpace(prefix) } ruleBuf.WriteString(prefix) ruleBuf.WriteString(line) ruleBuf.WriteByte('\n') } ruleBuf.WriteString(leftRuleEnd) return ruleBuf.String(), nil } func (render *HumanRender) SourceSnippets(diag *diagnostic.Diagnostic) (string, error) { if diag.Range == nil || diag.Snippet == nil { // This should generally not happen, as long as sources are always // loaded through the main loader. We may load things in other // ways in weird cases, so we'll tolerate it at the expense of // a not-so-helpful error message. return fmt.Sprintf(" on %s line %d:\n (source code not available)\n", diag.Range.Filename, diag.Range.Start.Line), nil } var ( buf = new(bytes.Buffer) snippet = diag.Snippet code = snippet.Code ) var contextStr string if snippet.Context != "" { contextStr = ", in " + snippet.Context } if _, err := fmt.Fprintf(buf, " on %s line %d%s:\n", diag.Range.Filename, diag.Range.Start.Line, contextStr); err != nil { return "", errors.New(err) } // Split the snippet and render the highlighted section with underlines start := snippet.HighlightStartOffset end := snippet.HighlightEndOffset // Only buggy diagnostics can have an end range before the start, but // we need to ensure we don't crash here if that happens. if end < start { end = min(start+1, len(code)) } // If either start or end is out of range for the code buffer then // we'll cap them at the bounds just to avoid a panic, although // this would happen only if there's a bug in the code generating // the snippet objects. if start < 0 { start = 0 } else if start > len(code) { start = len(code) } if end < 0 { end = 0 } else if end > len(code) { end = len(code) } before, highlight, after := code[0:start], code[start:end], code[end:] code = fmt.Sprintf(render.colorize.Color("%s[underline][white]%s[reset]%s"), before, highlight, after) // Split the snippet into lines and render one at a time lines := strings.Split(code, "\n") for i, line := range lines { if _, err := fmt.Fprintf( buf, "%4d: %s\n", snippet.StartLine+i, line, ); err != nil { return "", errors.New(err) } } if len(snippet.Values) > 0 || (snippet.FunctionCall != nil && snippet.FunctionCall.Signature != nil) { // The diagnostic may also have information about the dynamic // values of relevant variables at the point of evaluation. // This is particularly useful for expressions that get evaluated // multiple times with different values, such as blocks using // "count" and "for_each", or within "for" expressions. values := make([]diagnostic.ExpressionValue, len(snippet.Values)) copy(values, snippet.Values) sort.Slice(values, func(i, j int) bool { return values[i].Traversal < values[j].Traversal }) fmt.Fprint(buf, render.colorize.Color(" [dark_gray]├────────────────[reset]\n")) if callInfo := snippet.FunctionCall; callInfo != nil && callInfo.Signature != nil { if _, err := fmt.Fprintf(buf, render.colorize.Color(" [dark_gray]│[reset] while calling [bold]%s[reset]("), callInfo.CalledAs); err != nil { return "", errors.New(err) } for i, param := range callInfo.Signature.Params { if i > 0 { buf.WriteString(", ") } buf.WriteString(param.Name) } if param := callInfo.Signature.VariadicParam; param != nil { if len(callInfo.Signature.Params) > 0 { buf.WriteString(", ") } buf.WriteString(param.Name) buf.WriteString("...") } buf.WriteString(")\n") } for _, value := range values { if _, err := fmt.Fprintf(buf, render.colorize.Color(" [dark_gray]│[reset] [bold]%s[reset] %s\n"), value.Traversal, value.Statement); err != nil { return "", errors.New(err) } } } buf.WriteByte('\n') return buf.String(), nil } ================================================ FILE: internal/view/json_render.go ================================================ package view import ( "encoding/json" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/view/diagnostic" ) type JSONRender struct{} func NewJSONRender() Render { return &JSONRender{} } func (render *JSONRender) Diagnostics(diags diagnostic.Diagnostics) (string, error) { return render.toJSON(diags) } func (render *JSONRender) ShowConfigPath(filenames []string) (string, error) { return render.toJSON(filenames) } func (render *JSONRender) toJSON(val any) (string, error) { jsonBytes, err := json.Marshal(val) if err != nil { return "", errors.New(err) } if len(jsonBytes) == 0 { return "", nil } jsonBytes = append(jsonBytes, '\n') return string(jsonBytes), nil } ================================================ FILE: internal/view/view.go ================================================ // Package view contains the rendering logic for terragrunt. package view ================================================ FILE: internal/view/writer.go ================================================ package view import ( "fmt" "io" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/view/diagnostic" ) type Render interface { // Diagnostics renders early diagnostics, resulting from argument parsing. Diagnostics(diags diagnostic.Diagnostics) (string, error) // ShowConfigPath renders paths to configurations that contain errors. ShowConfigPath(filenames []string) (string, error) } // Writer is the base layer for command views, encapsulating a set of I/O streams, a colorize implementation, and implementing a human friendly view for diagnostics. type Writer struct { io.Writer render Render } func NewWriter(writer io.Writer, render Render) *Writer { return &Writer{ Writer: writer, render: render, } } func (writer *Writer) Diagnostics(diags diagnostic.Diagnostics) error { output, err := writer.render.Diagnostics(diags) if err != nil { return err } return writer.output(output) } func (writer *Writer) ShowConfigPath(diags diagnostic.Diagnostics) error { var filenames []string for _, diag := range diags { if diag.Range != nil && diag.Range.Filename != "" && !slices.Contains(filenames, diag.Range.Filename) { filenames = append(filenames, diag.Range.Filename) } } output, err := writer.render.ShowConfigPath(filenames) if err != nil { return err } return writer.output(output) } func (writer *Writer) output(output string) error { if _, err := fmt.Fprint(writer, output); err != nil { return errors.New(err) } return nil } ================================================ FILE: internal/worker/worker.go ================================================ // Package worker provides a concurrent task execution system with a configurable number of workers. // // It allows for controlled parallel execution of tasks while managing resources efficiently through // a semaphore-based worker pool. Key features include: // // - Configurable maximum number of concurrent workers // - Non-blocking task submission // - Graceful shutdown capabilities // - Error collection and aggregation // - Thread-safe operations // // The Pool struct manages a pool of workers that can execute tasks concurrently while // limiting the number of goroutines running simultaneously. This prevents resource exhaustion // while maximizing throughput. // // This implementation is particularly useful for scenarios where you need to process many // independent tasks with controlled parallelism, such as in infrastructure management tools, // batch processing systems, or any application requiring concurrent execution with resource // constraints. package worker import ( "sync" "sync/atomic" "github.com/gruntwork-io/terragrunt/internal/errors" ) // Task represents a unit of work that can be executed type Task func() error // Pool manages concurrent task execution with a configurable number of workers type Pool struct { semaphore chan struct{} allErrors *errors.MultiError wg sync.WaitGroup maxWorkers int mu sync.RWMutex allErrorsMu sync.RWMutex isStopping atomic.Bool isRunning bool } // NewWorkerPool creates a new worker pool with the specified maximum number of concurrent workers func NewWorkerPool(maxWorkers int) *Pool { if maxWorkers <= 0 { maxWorkers = 1 } return &Pool{ maxWorkers: maxWorkers, semaphore: make(chan struct{}, maxWorkers), isRunning: false, allErrors: &errors.MultiError{}, } } // Start initializes the worker pool func (wp *Pool) Start() { wp.mu.Lock() if wp.isRunning { wp.mu.Unlock() return } wp.isRunning = true wp.isStopping.Store(false) wp.semaphore = make(chan struct{}, wp.maxWorkers) // Reset allErrors wp.allErrorsMu.Lock() wp.allErrors = &errors.MultiError{} wp.allErrorsMu.Unlock() wp.mu.Unlock() } // appendError safely appends an error to allErrors func (wp *Pool) appendError(err error) { if err == nil { return } wp.allErrorsMu.Lock() wp.allErrors = wp.allErrors.Append(err) wp.allErrorsMu.Unlock() } // Submit adds a new task and starts a goroutine to execute it when a worker is available func (wp *Pool) Submit(task Task) { wp.mu.RLock() notRunning := !wp.isRunning wp.mu.RUnlock() if notRunning { wp.Start() } // Don't submit new tasks if the pool is stopping if wp.isStopping.Load() { return } wp.wg.Add(1) // Start a new goroutine for each task, but limit concurrency with semaphore go func() { defer wp.wg.Done() wp.semaphore <- struct{}{} defer func() { <-wp.semaphore }() err := task() if err != nil { wp.appendError(err) } }() } // Wait blocks until all tasks are completed and returns any errors func (wp *Pool) Wait() error { // Wait for all tasks to complete wp.wg.Wait() // Get all collected errors wp.allErrorsMu.RLock() result := wp.allErrors.ErrorOrNil() wp.allErrorsMu.RUnlock() return result } // Stop shuts down the worker pool after current tasks are completed func (wp *Pool) Stop() { wp.mu.Lock() defer wp.mu.Unlock() if wp.isRunning { // Mark as stopping to prevent new task submissions wp.isStopping.Store(true) go func() { wp.wg.Wait() wp.mu.Lock() wp.isRunning = false wp.mu.Unlock() }() } } // GracefulStop waits for all tasks to complete before stopping the pool func (wp *Pool) GracefulStop() error { // Mark as stopping to prevent new task submissions wp.isStopping.Store(true) // Wait for all tasks to complete and capture any errors err := wp.Wait() // Now fully stop the pool wp.mu.Lock() defer wp.mu.Unlock() if wp.isRunning { wp.isRunning = false } return err } // IsRunning returns whether the pool is currently running func (wp *Pool) IsRunning() bool { wp.mu.RLock() defer wp.mu.RUnlock() return wp.isRunning } // IsStopping returns whether the pool is in the process of stopping func (wp *Pool) IsStopping() bool { return wp.isStopping.Load() } ================================================ FILE: internal/worker/worker_test.go ================================================ package worker_test import ( "sync/atomic" "testing" "github.com/gruntwork-io/terragrunt/internal/worker" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/stretchr/testify/require" ) func TestAllTasksCompleteWithoutErrors(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(5) defer wp.Stop() var counter int32 // Submit 10 tasks that increment a counter for range 10 { wp.Submit(func() error { atomic.AddInt32(&counter, 1) return nil }) } // Wait for all tasks to complete errs := wp.Wait() require.NoError(t, errs) if atomic.LoadInt32(&counter) != 10 { t.Errorf("expected counter to be 10, got %d", counter) } } func TestSubmitLessAllTasksCompleteWithoutErrors(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(10) defer wp.Stop() var counter int32 for range 5 { wp.Submit(func() error { atomic.AddInt32(&counter, 1) return nil }) } // Wait for all tasks to complete errs := wp.Wait() require.NoError(t, errs) if atomic.LoadInt32(&counter) != 5 { t.Errorf("expected counter to be 5, got %d", counter) } } func TestSomeTasksReturnErrors(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(3) defer wp.Stop() var successCount int32 // Submit tasks, half of which return an error for i := range 10 { wp.Submit(func() error { if i%2 == 0 { return errors.New("mock error") } atomic.AddInt32(&successCount, 1) return nil }) } errs := wp.Wait() require.Error(t, errs) var multiErr *errors.MultiError require.True(t, errors.As(errs, &multiErr), "expected *errors.MultiError, got %T", errs) require.Len(t, multiErr.WrappedErrors(), 5, "expected exactly 5 errors, got %d", len(multiErr.WrappedErrors())) if atomic.LoadInt32(&successCount) != 5 { t.Errorf("expected successCount to be 5, got %d", successCount) } } func TestStopAndRestart(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(2) var counter int32 // Submit some tasks for range 5 { wp.Submit(func() error { atomic.AddInt32(&counter, 1) return nil }) } // Wait for all tasks to complete and stop the pool err := wp.Wait() require.NoError(t, err) wp.Stop() finalCount := atomic.LoadInt32(&counter) require.Equal(t, int32(5), finalCount, "expected counter to be 5") // Create a new worker pool instead of assuming restart wp = worker.NewWorkerPool(2) defer wp.Stop() // Submit new tasks for range 3 { wp.Submit(func() error { atomic.AddInt32(&counter, 1) return nil }) } errs := wp.Wait() require.NoError(t, errs) finalCountAfterRestart := atomic.LoadInt32(&counter) require.Equal(t, int32(8), finalCountAfterRestart, "expected counter to be 8") } func TestParallelSubmitsAndWaits(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(4) t.Cleanup(func() { wp.Stop() }) var totalCount int32 t.Run("parallelTaskSubmit1", func(t *testing.T) { t.Parallel() localWp := worker.NewWorkerPool(4) // Create a new worker pool per subtest defer localWp.Stop() for range 10 { localWp.Submit(func() error { atomic.AddInt32(&totalCount, 1) return nil }) } err := localWp.Wait() require.NoError(t, err) }) t.Run("parallelTaskSubmit2", func(t *testing.T) { t.Parallel() localWp := worker.NewWorkerPool(4) // Create another fresh worker pool defer localWp.Stop() for range 15 { localWp.Submit(func() error { atomic.AddInt32(&totalCount, 1) return nil }) } err := localWp.Wait() require.NoError(t, err) }) } func TestValidateParallelSubmits(t *testing.T) { t.Parallel() wp := worker.NewWorkerPool(1) defer wp.Stop() var totalCount int32 // Submit 5 tasks for range 5 { wp.Submit(func() error { atomic.AddInt32(&totalCount, 1) return nil }) } errs := wp.Wait() require.NoError(t, errs) if atomic.LoadInt32(&totalCount) != 5 { t.Errorf("expected totalCount to be 5, got %d", totalCount) } } ================================================ FILE: internal/worktrees/worktrees.go ================================================ // Package worktrees provides functionality for creating and managing Git worktrees for operating across multiple // Git references. package worktrees import ( "context" "maps" "os" "path/filepath" "runtime" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "golang.org/x/sync/errgroup" ) // Worktrees is a map of WorktreePairs, and the Git runner used to create and manage the worktrees. // The key is the string representation of the GitExpression that generated the worktree pair. type Worktrees struct { WorktreePairs map[string]WorktreePair gitRunner *git.GitRunner OriginalWorkingDir string } // WorktreePair is a pair of worktrees, one for the from and one for the to reference, along with // the GitExpression that generated the diffs and the diff for that expression. type WorktreePair struct { GitExpression *filter.GitExpression Diffs *git.Diffs FromWorktree Worktree ToWorktree Worktree } // Worktree is collects a Git reference and the path to the associated worktree. type Worktree struct { Ref string Path string } // WorkingDir returns the path within a worktree that corresponds to the user's // original working directory. This is used for display purposes after discovery completes. func (w *Worktrees) WorkingDir(ctx context.Context, worktreePath string) string { if w.gitRunner == nil { return worktreePath } repoRoot, err := w.gitRunner.GetRepoRoot(ctx) if err != nil { return worktreePath } relPath, err := filepath.Rel(repoRoot, w.OriginalWorkingDir) if err != nil || relPath == "." { return worktreePath } return filepath.Join(worktreePath, relPath) } // DisplayPath translates a worktree path to the equivalent path in the original repository // for user-facing output. This is useful for logging and reporting where users expect to see // paths relative to their working directory, not temporary worktree paths. // If the path is not within a worktree, it returns the path unchanged. func (w *Worktrees) DisplayPath(worktreePath string) string { for _, pair := range w.WorktreePairs { for _, wt := range []Worktree{pair.FromWorktree, pair.ToWorktree} { // Use boundary-aware check to avoid false matches (e.g., "/tmp/work" vs "/tmp/work-other") if worktreePath == wt.Path || strings.HasPrefix(worktreePath, wt.Path+string(os.PathSeparator)) { // Get the relative path within the worktree relPath, err := filepath.Rel(wt.Path, worktreePath) if err != nil { return worktreePath } // Join with original working dir return filepath.Join(w.OriginalWorkingDir, relPath) } } } return worktreePath } // Cleanup removes all created Git worktrees and their temporary directories. func (w *Worktrees) Cleanup(ctx context.Context, l log.Logger) error { // Get repo remote for telemetry var repoRemote string if w.gitRunner != nil { repoRemote = w.gitRunner.GetRemoteURL(ctx) } return filter.TraceGitWorktreesCleanup(ctx, len(w.WorktreePairs), repoRemote, func(ctx context.Context) error { seen := make(map[string]struct{}) for _, pair := range w.WorktreePairs { for _, worktree := range []Worktree{pair.FromWorktree, pair.ToWorktree} { if _, ok := seen[worktree.Path]; ok { continue } seen[worktree.Path] = struct{}{} // Skip removal if the worktree path doesn't exist (may have been cleaned up already) if _, err := os.Stat(worktree.Path); os.IsNotExist(err) { l.Debugf("Worktree path %s already removed, skipping cleanup", worktree.Path) continue } err := filter.TraceGitWorktreeRemove(ctx, worktree.Ref, worktree.Path, func(ctx context.Context) error { return w.gitRunner.RemoveWorktree(ctx, worktree.Path) }) if err != nil { // If the error is due to the worktree not existing, log and continue // This can happen during parallel test execution or if cleanup runs twice errStr := err.Error() if strings.Contains(errStr, "No such file or directory") || strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "not a valid directory") { l.Debugf("Worktree for reference %s already cleaned up: %v", worktree.Ref, err) continue } return errors.Errorf( "failed to remove Git worktree for reference %s (%s): %w", worktree.Ref, worktree.Path, err, ) } } } return nil }) } type StackDiff struct { Added []*component.Stack Removed []*component.Stack Changed []StackDiffChangedPair } type StackDiffChangedPair struct { FromStack *component.Stack ToStack *component.Stack } // Stacks returns a slice of stacks that can be found in the diffs found in worktrees. // // This can be useful, as stacks need to be discovered in worktrees, generated, then diffed on-disk // to find changed units. // // They are returned as added, removed, and changed stacks, respectively. func (w *Worktrees) Stacks() StackDiff { stackDiff := StackDiff{ Added: []*component.Stack{}, Removed: []*component.Stack{}, Changed: []StackDiffChangedPair{}, } for _, pair := range w.WorktreePairs { fromWorktree := pair.FromWorktree.Path toWorktree := pair.ToWorktree.Path for _, added := range pair.Diffs.Added { if filepath.Base(added) != config.DefaultStackFile { continue } dir := filepath.Dir(added) stackDiff.Added = append( stackDiff.Added, component.NewStack(filepath.Join(toWorktree, dir)).WithDiscoveryContext( &component.DiscoveryContext{ WorkingDir: toWorktree, Ref: pair.ToWorktree.Ref, }, ), ) } for _, removed := range pair.Diffs.Removed { if filepath.Base(removed) != config.DefaultStackFile { continue } dir := filepath.Dir(removed) stackDiff.Removed = append( stackDiff.Removed, component.NewStack(filepath.Join(fromWorktree, dir)).WithDiscoveryContext( &component.DiscoveryContext{ WorkingDir: fromWorktree, Ref: pair.FromWorktree.Ref, }, ), ) } for _, changed := range pair.Diffs.Changed { if filepath.Base(changed) != config.DefaultStackFile { continue } dir := filepath.Dir(changed) stackDiff.Changed = append( stackDiff.Changed, StackDiffChangedPair{ FromStack: component.NewStack(filepath.Join(fromWorktree, dir)).WithDiscoveryContext( &component.DiscoveryContext{ WorkingDir: fromWorktree, Ref: pair.FromWorktree.Ref, }, ), ToStack: component.NewStack(filepath.Join(toWorktree, dir)).WithDiscoveryContext( &component.DiscoveryContext{ WorkingDir: toWorktree, Ref: pair.ToWorktree.Ref, }, ), }, ) } } return stackDiff } // Expand expands a worktree pair with an associated Git expression into the equivalent to and from filter // expressions based on the provided diffs for the worktree pair. func (wp *WorktreePair) Expand() (filter.Filters, filter.Filters, error) { diffs := wp.Diffs toPath := wp.ToWorktree.Path fromExpressions := make(filter.Expressions, 0, len(diffs.Removed)) toExpressions := make(filter.Expressions, 0, len(diffs.Added)+len(diffs.Changed)) // Build simple expressions that can be determined simply from the diffs. if err := expandDiffPaths(diffs.Removed, toPath, &fromExpressions, &toExpressions); err != nil { return nil, nil, err } if err := expandDiffPaths(diffs.Added, toPath, &toExpressions, &toExpressions); err != nil { return nil, nil, err } for _, path := range diffs.Changed { dir := filepath.Dir(path) switch filepath.Base(path) { case config.DefaultTerragruntConfigPath: expr, err := filter.NewPathFilter(dir) if err != nil { return nil, nil, errors.Errorf("failed to create path filter for %s: %w", dir, err) } toExpressions = append(toExpressions, expr) default: // Check to see if the changed file is in the same directory as a unit in the to worktree. // If so, we'll consider the unit modified. if _, err := os.Stat(filepath.Join(toPath, dir, config.DefaultTerragruntConfigPath)); err == nil { expr, err := filter.NewPathFilter(dir) if err != nil { return nil, nil, errors.Errorf("failed to create path filter for %s: %w", dir, err) } toExpressions = append(toExpressions, expr) continue } // Otherwise, we'll consider it a file that could potentially be read by other units, and needs to be // tracked using a reading filter. expr, err := filter.NewAttributeExpression(filter.AttributeReading, path) if err != nil { return nil, nil, errors.Errorf("failed to create reading filter for %s: %w", path, err) } toExpressions = append(toExpressions, expr) } } fromFilters := make(filter.Filters, 0, len(fromExpressions)) for _, expression := range fromExpressions { fromFilters = append( fromFilters, filter.NewFilter(expression, expression.String()), ) } toFilters := make(filter.Filters, 0, len(toExpressions)) for _, expression := range toExpressions { toFilters = append( toFilters, filter.NewFilter(expression, expression.String()), ) } return fromFilters, toFilters, nil } // NewWorktrees creates a new Worktrees for a given set of Git filters. // // Note that it is the responsibility of the caller to call Cleanup on the Worktrees object when it is no longer needed. func NewWorktrees( ctx context.Context, l log.Logger, workingDir string, gitExpressions filter.GitExpressions, ) (*Worktrees, error) { if len(gitExpressions) == 0 { return &Worktrees{ WorktreePairs: make(map[string]WorktreePair), OriginalWorkingDir: workingDir, }, nil } gitRefs := gitExpressions.UniqueGitRefs() var ( worktrees *Worktrees outerErr error ) gitRunner, err := git.NewGitRunner() if err != nil { return nil, errors.Errorf("failed to create Git runner for worktree creation: %w", err) } gitRunner = gitRunner.WithWorkDir(workingDir) // Get repo info for telemetry repoRemote := gitRunner.GetRemoteURL(ctx) repoBranch := gitRunner.GetCurrentBranch(ctx) repoCommit := gitRunner.GetHeadCommit(ctx) // Wrap entire worktree creation process with telemetry traceErr := filter.TraceGitWorktreesCreate(ctx, workingDir, len(gitRefs), repoRemote, repoBranch, repoCommit, func(ctx context.Context) error { var ( errs []error mu sync.Mutex ) expressionsToDiffs := make(map[*filter.GitExpression]*git.Diffs, len(gitExpressions)) gitCmdGroup, gitCmdCtx := errgroup.WithContext(ctx) gitCmdGroup.SetLimit(min(runtime.NumCPU(), len(gitRefs))) refsToPaths := make(map[string]string, len(gitRefs)) if len(gitRefs) > 0 { gitCmdGroup.Go(func() error { paths, err := createGitWorktrees(gitCmdCtx, l, gitRunner, gitRefs, repoRemote, repoBranch, repoCommit) if err != nil { mu.Lock() errs = append(errs, err) mu.Unlock() return err } mu.Lock() maps.Copy(refsToPaths, paths) mu.Unlock() return nil }) } for _, gitExpression := range gitExpressions { gitCmdGroup.Go(func() error { // Wrap git diff with telemetry var diffs *git.Diffs diffErr := filter.TraceGitDiff(gitCmdCtx, gitExpression.FromRef, gitExpression.ToRef, repoRemote, func(ctx context.Context) error { var err error diffs, err = gitRunner.Diff(ctx, gitExpression.FromRef, gitExpression.ToRef) return err }) if diffErr != nil { mu.Lock() errs = append(errs, diffErr) mu.Unlock() return nil } mu.Lock() expressionsToDiffs[gitExpression] = diffs mu.Unlock() return nil }) } if err := gitCmdGroup.Wait(); err != nil { worktrees = &Worktrees{ WorktreePairs: make(map[string]WorktreePair), OriginalWorkingDir: workingDir, gitRunner: gitRunner, } outerErr = err return err } worktreePairs := make(map[string]WorktreePair, len(gitExpressions)) for _, gitExpression := range gitExpressions { worktreePairs[gitExpression.String()] = WorktreePair{ GitExpression: gitExpression, Diffs: expressionsToDiffs[gitExpression], FromWorktree: Worktree{Ref: gitExpression.FromRef, Path: refsToPaths[gitExpression.FromRef]}, ToWorktree: Worktree{Ref: gitExpression.ToRef, Path: refsToPaths[gitExpression.ToRef]}, } // Record telemetry for diff results if diffs := expressionsToDiffs[gitExpression]; diffs != nil { recordDiffTelemetry(ctx, diffs) } } worktrees = &Worktrees{ WorktreePairs: worktreePairs, OriginalWorkingDir: workingDir, gitRunner: gitRunner, } if len(errs) > 0 { outerErr = errors.Join(errs...) return outerErr } return nil }) if traceErr != nil && outerErr == nil { l.Warnf("telemetry trace error during worktree creation: %v", traceErr) } // cleanup worktrees if outerErr != nil && worktrees != nil { if cleanupErr := worktrees.Cleanup(ctx, l); cleanupErr != nil { l.Warnf("failed to cleanup worktrees: %v", cleanupErr) } } return worktrees, outerErr } // expandDiffPaths processes a list of changed paths from a worktree diff, creating path filter expressions // for discovered units and stacks. primaryExprs receives filters for config files (units/stacks), // while fallbackExprs receives filters for non-config files adjacent to units in the "to" worktree. func expandDiffPaths(paths []string, toPath string, primaryExprs, fallbackExprs *filter.Expressions) error { for _, path := range paths { dir := filepath.Dir(path) switch filepath.Base(path) { case config.DefaultTerragruntConfigPath: expr, err := filter.NewPathFilter(dir) if err != nil { return errors.Errorf("failed to create path filter for %s: %w", dir, err) } *primaryExprs = append(*primaryExprs, expr) case config.DefaultStackFile: dirExpr, err := filter.NewPathFilter(dir) if err != nil { return errors.Errorf("failed to create path filter for %s: %w", dir, err) } globExpr, err := filter.NewPathFilter(filepath.Join(dir, "**")) if err != nil { return errors.Errorf("failed to create path filter for %s/**: %w", dir, err) } *primaryExprs = append(*primaryExprs, dirExpr, globExpr) default: if _, err := os.Stat(filepath.Join(toPath, dir, config.DefaultTerragruntConfigPath)); err == nil { expr, err := filter.NewPathFilter(dir) if err != nil { return errors.Errorf("failed to create path filter for %s: %w", dir, err) } *fallbackExprs = append(*fallbackExprs, expr) } } } return nil } // recordDiffTelemetry records telemetry metrics for git diff results. func recordDiffTelemetry(ctx context.Context, diffs *git.Diffs) { telemeter := telemetry.TelemeterFromContext(ctx) if telemeter == nil || telemeter.Meter == nil { return } telemeter.Count(ctx, "git_diff_files_added", int64(len(diffs.Added))) telemeter.Count(ctx, "git_diff_files_removed", int64(len(diffs.Removed))) telemeter.Count(ctx, "git_diff_files_changed", int64(len(diffs.Changed))) } // createGitWorktrees creates detached worktrees for each unique Git reference needed by filters. // The worktrees are created in temporary directories and tracked in refsToPaths. // Worktrees are created sequentially because git worktree operations on the same repository // are not thread-safe - concurrent calls to `git worktree add` can cause race conditions // accessing the `.git/worktrees/` directory. func createGitWorktrees( ctx context.Context, l log.Logger, gitRunner *git.GitRunner, gitRefs []string, repoRemote, repoBranch, repoCommit string, ) (map[string]string, error) { var errs []error refsToPaths := make(map[string]string, len(gitRefs)) for _, ref := range gitRefs { tmpDir, err := os.MkdirTemp("", "terragrunt-worktree-"+sanitizeRef(ref)+"-*") if err != nil { errs = append(errs, errors.Errorf("failed to create temporary directory for worktree: %w", err)) continue } // macOS will create the temporary directory with symlinks, so we need to evaluate them. origTmpDir := tmpDir tmpDir, err = filepath.EvalSymlinks(tmpDir) if err != nil { if cleanErr := os.RemoveAll(origTmpDir); cleanErr != nil { l.Warnf("failed to clean worktree directory %s: %v", origTmpDir, cleanErr) } errs = append(errs, errors.Errorf("failed to evaluate symlinks for temporary directory: %w", err)) continue } // Wrap individual worktree creation with telemetry including repo info err = filter.TraceGitWorktreeCreate(ctx, ref, tmpDir, repoRemote, repoBranch, repoCommit, func(ctx context.Context) error { return gitRunner.CreateDetachedWorktree(ctx, tmpDir, ref) }) if err != nil { if cleanErr := os.RemoveAll(tmpDir); cleanErr != nil { l.Warnf("failed to clean worktree directory %s: %v", tmpDir, cleanErr) } errs = append(errs, errors.Errorf("failed to create Git worktree for reference %s: %w", ref, err)) continue } refsToPaths[ref] = tmpDir l.Debugf("Created Git worktree for reference %s at %s", ref, tmpDir) } if len(errs) > 0 { return refsToPaths, errors.Join(errs...) } return refsToPaths, nil } // sanitizeRef sanitizes a Git reference string for use in file paths. // It replaces invalid characters with underscores. func sanitizeRef(ref string) string { result := strings.Builder{} for _, r := range ref { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { result.WriteRune(r) continue } result.WriteRune('_') } return result.String() } ================================================ FILE: internal/worktrees/worktrees_test.go ================================================ package worktrees_test import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/git" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object" ) func TestNewWorktrees(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) err = runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) err = runner.GoCommit("Second commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"[HEAD~1...HEAD]"}) require.NoError(t, err) w, err := worktrees.NewWorktrees( t.Context(), logger.CreateLogger(), tmpDir, filters.UniqueGitFilters(), ) require.NoError(t, err) t.Cleanup(func() { cleanupErr := w.Cleanup(context.Background(), logger.CreateLogger()) require.NoError(t, cleanupErr) }) require.NotEmpty(t, w.WorktreePairs) } func TestNewWorktreesWithInvalidReference(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Initialize Git repository runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) err = runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir // Parse filter with invalid Git reference filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"[nonexistent-branch]"}) require.NoError(t, err) // Parsing should succeed _, err = worktrees.NewWorktrees( t.Context(), logger.CreateLogger(), tmpDir, filters.UniqueGitFilters(), ) require.Error(t, err) } func TestExpressionExpansion(t *testing.T) { t.Parallel() tests := []struct { diffs *git.Diffs name string expectedToPaths []string expectedToReadings []string expectedFrom int expectedTo int }{ { name: "removed terragrunt.hcl files create from filters", diffs: &git.Diffs{ Removed: []string{ "app1/terragrunt.hcl", "app2/terragrunt.hcl", }, }, expectedFrom: 2, expectedTo: 0, expectedToPaths: []string{}, expectedToReadings: []string{}, }, { name: "added terragrunt.hcl files create to filters", diffs: &git.Diffs{ Added: []string{ "app1/terragrunt.hcl", "app2/terragrunt.hcl", }, }, expectedFrom: 0, expectedTo: 2, expectedToPaths: []string{"app1", "app2"}, expectedToReadings: []string{}, }, { name: "changed terragrunt.hcl files create to filters", diffs: &git.Diffs{ Changed: []string{ "app1/terragrunt.hcl", "app2/terragrunt.hcl", }, }, expectedFrom: 0, expectedTo: 2, expectedToPaths: []string{"app1", "app2"}, expectedToReadings: []string{}, }, { name: "changed non-terragrunt.hcl files create reading filters", diffs: &git.Diffs{ Changed: []string{ "app1/main.tf", "app1/variables.tf", "app2/data.tf", }, }, expectedFrom: 0, expectedTo: 3, expectedToPaths: []string{}, expectedToReadings: []string{"app1/main.tf", "app1/variables.tf", "app2/data.tf"}, }, { name: "changed stack files create reading filters", diffs: &git.Diffs{ Changed: []string{ "stack/terragrunt.stack.hcl", }, }, expectedFrom: 0, expectedTo: 1, expectedToPaths: []string{}, expectedToReadings: []string{"stack/terragrunt.stack.hcl"}, }, { name: "mixed file types create appropriate filters", diffs: &git.Diffs{ Removed: []string{ "app-removed/terragrunt.hcl", }, Added: []string{ "app-added/terragrunt.hcl", }, Changed: []string{ "app-modified/terragrunt.hcl", "app-modified/main.tf", "stack/terragrunt.stack.hcl", "other/file.hcl", }, }, expectedFrom: 1, expectedTo: 5, expectedToPaths: []string{"app-added", "app-modified"}, expectedToReadings: []string{"app-modified/main.tf", "stack/terragrunt.stack.hcl", "other/file.hcl"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) err = runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) wp := &worktrees.WorktreePair{ Diffs: tt.diffs, } fromFilters, toFilters, err := wp.Expand() require.NoError(t, err) // Verify from filters count assert.Len(t, fromFilters, tt.expectedFrom, "From filters count should match") // Verify to filters count assert.Len(t, toFilters, tt.expectedTo, "To filters count should match") // Verify from filters are path filters with correct paths for i, f := range fromFilters { pathExpr, ok := f.Expression().(*filter.PathExpression) require.True(t, ok, "From filter %d should be a PathExpression", i) expectedPath := filepath.Dir(tt.diffs.Removed[i]) assert.Equal(t, expectedPath, pathExpr.Value, "From filter %d should have correct path", i) } // Verify to filters toPaths := []string{} toReadings := []string{} for _, f := range toFilters { switch expr := f.Expression().(type) { case *filter.PathExpression: toPaths = append(toPaths, expr.Value) case *filter.AttributeExpression: if expr.Key == "reading" { toReadings = append(toReadings, expr.Value) } } } // Verify path filters assert.ElementsMatch(t, tt.expectedToPaths, toPaths, "To path filters should match") // Verify reading filters assert.ElementsMatch(t, tt.expectedToReadings, toReadings, "To reading filters should match") }) } } func TestExpansionAttributeReadingFilters(t *testing.T) { t.Parallel() tests := []struct { name string diffs *git.Diffs expectedReadings []string }{ { name: "changed .tf file creates reading filter", diffs: &git.Diffs{ Changed: []string{ "app/main.tf", }, }, expectedReadings: []string{"app/main.tf"}, }, { name: "changed .hcl file (not terragrunt.hcl) creates reading filter", diffs: &git.Diffs{ Changed: []string{ "app/config.hcl", }, }, expectedReadings: []string{"app/config.hcl"}, }, { name: "changed file in subdirectory creates reading filter with correct path", diffs: &git.Diffs{ Changed: []string{ "app/modules/database/main.tf", }, }, expectedReadings: []string{"app/modules/database/main.tf"}, }, { name: "multiple changed files create multiple reading filters", diffs: &git.Diffs{ Changed: []string{ "app1/main.tf", "app1/variables.tf", "app2/data.tf", "app2/outputs.tf", }, }, expectedReadings: []string{ "app1/main.tf", "app1/variables.tf", "app2/data.tf", "app2/outputs.tf", }, }, { name: "mixed terragrunt.hcl and other files", diffs: &git.Diffs{ Changed: []string{ "app/terragrunt.hcl", "app/main.tf", "app/variables.tf", }, }, expectedReadings: []string{ "app/main.tf", "app/variables.tf", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) err = runner.GoCommit("Initial commit", &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) wp := &worktrees.WorktreePair{ Diffs: tt.diffs, } _, toFilters, err := wp.Expand() require.NoError(t, err) // Extract reading filters readings := []string{} for _, f := range toFilters { if attrExpr, ok := f.Expression().(*filter.AttributeExpression); ok { if attrExpr.Key == "reading" { readings = append(readings, attrExpr.Value) } } } // Verify reading filters match expected assert.ElementsMatch(t, tt.expectedReadings, readings, "Reading filters should match expected paths") // Verify each reading filter is properly constructed for _, expectedReading := range tt.expectedReadings { found := false for _, f := range toFilters { if attrExpr, ok := f.Expression().(*filter.AttributeExpression); ok { if attrExpr.Key == "reading" && attrExpr.Value == expectedReading { found = true assert.Equal(t, "reading", attrExpr.Key, "Filter should have reading key") assert.Equal(t, expectedReading, attrExpr.Value, "Filter should have correct file path") break } } } assert.True(t, found, "Expected reading filter for %s should be present", expectedReading) } }) } } func TestExpandWithUnitDirectoryDetection(t *testing.T) { t.Parallel() tests := []struct { name string setupFilesystem func(tmpDir string) error diffs *git.Diffs expectedToPaths []string expectedToReadings []string expectedFrom int }{ { name: "removed file in unit directory creates path filter", setupFilesystem: func(tmpDir string) error { // Create unit directory with terragrunt.hcl unitDir := filepath.Join(tmpDir, "unit1") if err := os.MkdirAll(unitDir, 0755); err != nil { return err } terragruntFile := filepath.Join(unitDir, "terragrunt.hcl") return os.WriteFile(terragruntFile, []byte("# terragrunt config"), 0644) }, diffs: &git.Diffs{ Removed: []string{ "unit1/main.tf", }, }, expectedToPaths: []string{"unit1"}, expectedToReadings: []string{}, expectedFrom: 0, }, { name: "removed file in non-unit directory creates no filter", setupFilesystem: func(tmpDir string) error { // Create non-unit directory (no terragrunt.hcl) nonUnitDir := filepath.Join(tmpDir, "non-unit") return os.MkdirAll(nonUnitDir, 0755) }, diffs: &git.Diffs{ Removed: []string{ "non-unit/some-file.tf", }, }, expectedToPaths: []string{}, expectedToReadings: []string{}, expectedFrom: 0, }, { name: "added file in unit directory creates path filter", setupFilesystem: func(tmpDir string) error { // Create unit directory with terragrunt.hcl unitDir := filepath.Join(tmpDir, "unit1") if err := os.MkdirAll(unitDir, 0755); err != nil { return err } terragruntFile := filepath.Join(unitDir, "terragrunt.hcl") return os.WriteFile(terragruntFile, []byte("# terragrunt config"), 0644) }, diffs: &git.Diffs{ Added: []string{ "unit1/variables.tf", }, }, expectedToPaths: []string{"unit1"}, expectedToReadings: []string{}, expectedFrom: 0, }, { name: "added file in non-unit directory creates no filter", setupFilesystem: func(tmpDir string) error { // Create non-unit directory (no terragrunt.hcl) nonUnitDir := filepath.Join(tmpDir, "non-unit") return os.MkdirAll(nonUnitDir, 0755) }, diffs: &git.Diffs{ Added: []string{ "non-unit/new-file.tf", }, }, expectedToPaths: []string{}, expectedToReadings: []string{}, expectedFrom: 0, }, { name: "changed file in unit directory creates path filter", setupFilesystem: func(tmpDir string) error { // Create unit directory with terragrunt.hcl unitDir := filepath.Join(tmpDir, "unit1") if err := os.MkdirAll(unitDir, 0755); err != nil { return err } terragruntFile := filepath.Join(unitDir, "terragrunt.hcl") return os.WriteFile(terragruntFile, []byte("# terragrunt config"), 0644) }, diffs: &git.Diffs{ Changed: []string{ "unit1/main.tf", }, }, expectedToPaths: []string{"unit1"}, expectedToReadings: []string{}, expectedFrom: 0, }, { name: "changed file in non-unit directory creates reading filter", setupFilesystem: func(tmpDir string) error { // Create non-unit directory (no terragrunt.hcl) nonUnitDir := filepath.Join(tmpDir, "non-unit") return os.MkdirAll(nonUnitDir, 0755) }, diffs: &git.Diffs{ Changed: []string{ "non-unit/some-file.tf", }, }, expectedToPaths: []string{}, expectedToReadings: []string{"non-unit/some-file.tf"}, expectedFrom: 0, }, { name: "mixed scenarios with multiple units and non-units", setupFilesystem: func(tmpDir string) error { // Create unit1 directory unit1Dir := filepath.Join(tmpDir, "unit1") if err := os.MkdirAll(unit1Dir, 0755); err != nil { return err } terragruntFile1 := filepath.Join(unit1Dir, "terragrunt.hcl") if err := os.WriteFile(terragruntFile1, []byte("# terragrunt config"), 0644); err != nil { return err } // Create unit2 directory unit2Dir := filepath.Join(tmpDir, "unit2") if err := os.MkdirAll(unit2Dir, 0755); err != nil { return err } terragruntFile2 := filepath.Join(unit2Dir, "terragrunt.hcl") if err := os.WriteFile(terragruntFile2, []byte("# terragrunt config"), 0644); err != nil { return err } // Create non-unit directory nonUnitDir := filepath.Join(tmpDir, "non-unit") return os.MkdirAll(nonUnitDir, 0755) }, diffs: &git.Diffs{ Removed: []string{ "unit1/old-file.tf", }, Added: []string{ "unit2/new-file.tf", }, Changed: []string{ "unit1/modified.tf", "non-unit/shared.tf", }, }, expectedToPaths: []string{"unit1", "unit2"}, expectedToReadings: []string{"non-unit/shared.tf"}, expectedFrom: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Setup filesystem structure err := tt.setupFilesystem(tmpDir) require.NoError(t, err) wp := &worktrees.WorktreePair{ Diffs: tt.diffs, ToWorktree: worktrees.Worktree{ Path: tmpDir, }, } fromFilters, toFilters, err := wp.Expand() require.NoError(t, err) // Verify from filters count assert.Len(t, fromFilters, tt.expectedFrom, "From filters count should match") // Extract path and reading filters from toFilters toPathsMap := make(map[string]bool) toReadings := []string{} for _, f := range toFilters { switch expr := f.Expression().(type) { case *filter.PathExpression: toPathsMap[expr.Value] = true case *filter.AttributeExpression: if expr.Key == "reading" { toReadings = append(toReadings, expr.Value) } } } // Convert map to slice for comparison (deduplicates) toPaths := make([]string, 0, len(toPathsMap)) for path := range toPathsMap { toPaths = append(toPaths, path) } // Verify path filters assert.ElementsMatch(t, tt.expectedToPaths, toPaths, "To path filters should match") // Verify reading filters assert.ElementsMatch(t, tt.expectedToReadings, toReadings, "To reading filters should match") }) } } // TestWorktreeCleanup test worktree cleanup func TestWorktreeCleanup(t *testing.T) { t.Parallel() tmpDir := t.TempDir() tmpDir, err := filepath.EvalSymlinks(tmpDir) require.NoError(t, err) // Initialize Git repository runner, err := git.NewGitRunner() require.NoError(t, err) runner = runner.WithWorkDir(tmpDir) err = runner.Init(t.Context()) require.NoError(t, err) err = runner.GoOpenRepo() require.NoError(t, err) t.Cleanup(func() { err = runner.GoCloseStorage() if err != nil { t.Logf("Error closing storage: %s", err) } }) for i := range 3 { err = runner.GoCommit(fmt.Sprintf("Commit %d", i), &gogit.CommitOptions{ AllowEmptyCommits: true, Author: &object.Signature{ Name: "Test User", Email: "test@example.com", When: time.Now(), }, }) require.NoError(t, err) } opts := options.NewTerragruntOptions() opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"[test-worktree-cleanup]"}) require.NoError(t, err) _, err = worktrees.NewWorktrees( t.Context(), logger.CreateLogger(), tmpDir, filters.UniqueGitFilters(), ) require.Error(t, err) tempDir := os.TempDir() worktreeDirs, err := filepath.Glob(filepath.Join(tempDir, "terragrunt-worktree-*")) require.NoError(t, err) // validate that test-worktree-cleanup worktree was deleted worktreeExists := false for _, dir := range worktreeDirs { if strings.Contains(filepath.Base(dir), "test-worktree-cleanup") { worktreeExists = true break } } assert.False(t, worktreeExists, "Worktree test-worktree-cleanup should be deleted") } ================================================ FILE: internal/writer/writer.go ================================================ // Package writer provides writer types for Terragrunt I/O. package writer import "io" // Writers groups the writer-related fields that travel together across // TerragruntOptions, ParsingContext, shell.RunOptions and engine.ExecutionOptions. type Writers struct { // Writer is the primary output writer (defaults to os.Stdout). Writer io.Writer // ErrWriter is the error output writer (defaults to os.Stderr). ErrWriter io.Writer // LogShowAbsPaths disables replacing full paths in logs with short relative paths. LogShowAbsPaths bool // LogDisableErrorSummary is a flag to skip the error summary. LogDisableErrorSummary bool } // writerUnwrapper is any writer that can provide its underlying parent writer. // This interface allows extracting the original writer from wrapped writers. type writerUnwrapper interface { Unwrap() io.Writer } // OriginalWriter wraps an io.Writer and implements writerUnwrapper to preserve // access to the original writer even after it's been wrapped by other writers. // This is used to maintain access to the original stdout/stderr writers after they // are wrapped by log writers in logTFOutput. type OriginalWriter struct { w io.Writer } // NewOriginalWriter creates a new OriginalWriter that wraps the given writer. func NewOriginalWriter(w io.Writer) *OriginalWriter { return &OriginalWriter{w: w} } // Write implements io.Writer by delegating to the wrapped writer. func (ow *OriginalWriter) Write(p []byte) (int, error) { return ow.w.Write(p) } // Unwrap implements writerUnwrapper by returning the wrapped writer. func (ow *OriginalWriter) Unwrap() io.Writer { return ow.w } // WrappedWriter wraps an io.Writer and implements writerUnwrapper to preserve // access to an underlying original writer. This is used to wrap the result of // buildOutWriter/buildErrWriter so the original writer can still be extracted. type WrappedWriter struct { wrapped io.Writer original io.Writer } // NewWrappedWriter creates a new WrappedWriter that wraps the given writer // and preserves access to the original writer. func NewWrappedWriter(wrapped, original io.Writer) *WrappedWriter { return &WrappedWriter{ wrapped: wrapped, original: original, } } // Write implements io.Writer by delegating to the wrapped writer. func (ww *WrappedWriter) Write(p []byte) (int, error) { return ww.wrapped.Write(p) } // Unwrap implements writerUnwrapper by returning the original writer. func (ww *WrappedWriter) Unwrap() io.Writer { return ww.original } // ExtractOriginalWriter extracts the original writer from a potentially wrapped writer. // If the writer implements writerUnwrapper, it recursively extracts the parent. // Otherwise, it returns the writer as-is. func ExtractOriginalWriter(w io.Writer) io.Writer { if w == nil { return nil } if u, ok := w.(writerUnwrapper); ok { parent := u.Unwrap() return ExtractOriginalWriter(parent) } return w } ================================================ FILE: main.go ================================================ package main import ( "context" "os" "github.com/gruntwork-io/terragrunt/internal/cli" "github.com/gruntwork-io/terragrunt/internal/cli/flags/global" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" ) // The main entrypoint for Terragrunt func main() { exitCode := tf.NewDetailedExitCodeMap() opts := options.NewTerragruntOptions() l := log.New( log.WithOutput(opts.Writers.ErrWriter), log.WithLevel(options.DefaultLogLevel), log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) // Immediately parse the `TG_LOG_LEVEL` environment variable, e.g. to set the TRACE level. if err := global.NewLogLevelFlag(l, opts, nil).Parse(os.Args); err != nil { l.Error(err.Error()) os.Exit(1) } defer func() { if opts.TerraformCliArgs.Contains(tf.FlagNameDetailedExitCode) { errors.Recover(checkForErrorsAndExit(l, exitCode.GetFinalDetailedExitCode())) return } errors.Recover(checkForErrorsAndExit(l, exitCode.GetFinalExitCode())) }() app := cli.NewApp(l, opts) ctx := setupContext(l, exitCode) err := app.RunContext(ctx, os.Args) if opts.TerraformCliArgs.Contains(tf.FlagNameDetailedExitCode) { checkForErrorsAndExit(l, exitCode.GetFinalDetailedExitCode())(err) return } checkForErrorsAndExit(l, exitCode.GetFinalExitCode())(err) } // If there is an error, display it in the console and exit with a non-zero exit code. Otherwise, exit 0. func checkForErrorsAndExit(l log.Logger, exitCode int) func(error) { return func(err error) { if err == nil { os.Exit(exitCode) } l.Error(err.Error()) if errStack := errors.ErrorStack(err); errStack != "" { l.Trace(errStack) } // exit with the underlying error code exitCoder, exitCodeErr := util.GetExitCode(err) if exitCodeErr != nil { exitCoder = 1 } if explain := shell.ExplainError(err); len(explain) > 0 { l.Errorf("Suggested fixes: \n%s", explain) } os.Exit(exitCoder) } } func setupContext(l log.Logger, exitCode *tf.DetailedExitCodeMap) context.Context { ctx := context.Background() ctx = tf.ContextWithDetailedExitCode(ctx, exitCode) return log.ContextWithLogger(ctx, l) } ================================================ FILE: mise.cicd.toml ================================================ [tools] go-junit-report = "2.1.0" pre-commit = "4.2.0" gcloud = "535.0.0" awscli = "2.28.15" "go:golang.org/x/tools/cmd/goimports" = "latest" "go:golang.org/x/tools/gopls" = "v0.20.0" tflint = "0.50.3" terraform = "1.14.4" pipx = { version = "1.8.0", os = ["macos", "linux"] } "pipx:codespell" = { version = "2.4.1", os = ["macos", "linux"] } ================================================ FILE: mise.toml ================================================ [tools] go = "1.26.0" opentofu = "1.11.4" golangci-lint = "2.9.0" "go:github.com/goph/licensei/cmd/licensei" = "v0.9.0" "go:go.uber.org/mock/mockgen" = "v0.6.0" "go:golang.org/x/tools/gopls" = "v0.20.0" "go:golang.org/x/tools/cmd/goimports" = "0.38.0" ================================================ FILE: pkg/config/cache_test.go ================================================ package config_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/stretchr/testify/assert" ) const testCacheName = "TerragruntConfig" func TestTerragruntConfigCacheCreation(t *testing.T) { t.Parallel() cache := cache.NewCache[config.TerragruntConfig](testCacheName) assert.NotNil(t, cache.Mutex) assert.NotNil(t, cache.Cache) assert.Empty(t, cache.Cache) } func TestTerragruntConfigCacheOperation(t *testing.T) { t.Parallel() testCacheKey := "super-safe-cache-key" ctx := t.Context() cache := cache.NewCache[config.TerragruntConfig](testCacheName) actualResult, found := cache.Get(ctx, testCacheKey) assert.False(t, found) assert.Empty(t, actualResult) stubTerragruntConfig := config.TerragruntConfig{ IsPartial: true, // Any random property will be sufficient } cache.Put(ctx, testCacheKey, stubTerragruntConfig) actualResult, found = cache.Get(ctx, testCacheKey) assert.True(t, found) assert.NotEmpty(t, actualResult) assert.Equal(t, stubTerragruntConfig, actualResult) } ================================================ FILE: pkg/config/catalog.go ================================================ package config import ( "context" "errors" "fmt" "path/filepath" "regexp" "github.com/gruntwork-io/go-commons/files" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/zclconf/go-cty/cty" ) const ( rootConfigFmt = ` include "root" { path = find_in_parent_folders("%s") } ` // matches a block and ignores commented out config, where the block name is passed as the first argument to fmt, e.g. // `fmt.Sprintf(hclBlockRegExprFmt, "include")` returns a regexp expression matching the `include` block: // // ```hcl // include { // // } // ``` hclBlockRegExprFmt = `(?is)(?:^|^((?:[^/]|/[^*])*)(?:/\*.*?\*/)?((?:[^/]|/[^*])*)\n)(%s[ {][^\}]+)` ) var ( includeBlockReg = regexp.MustCompile(fmt.Sprintf(hclBlockRegExprFmt, MetadataInclude)) catalogBlockReg = regexp.MustCompile(fmt.Sprintf(hclBlockRegExprFmt, MetadataCatalog)) ) type CatalogConfig struct { NoShell *bool `hcl:"no_shell,optional" cty:"no_shell"` NoHooks *bool `hcl:"no_hooks,optional" cty:"no_hooks"` DefaultTemplate string `hcl:"default_template,optional" cty:"default_template"` URLs []string `hcl:"urls,attr" cty:"urls"` } func (cfg *CatalogConfig) String() string { return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v, NoShell = %v, NoHooks = %v}", cfg.URLs, cfg.DefaultTemplate, cfg.NoShell, cfg.NoHooks) } func (cfg *CatalogConfig) normalize(configPath string) { configDir := filepath.Dir(configPath) // transform relative paths to absolute ones for i, url := range cfg.URLs { url := filepath.Join(configDir, url) if files.FileExists(url) { cfg.URLs[i] = url } } if cfg.DefaultTemplate != "" { path := filepath.Join(configDir, cfg.DefaultTemplate) if files.FileExists(path) { cfg.DefaultTemplate = path } } } // ReadCatalogConfig reads the `catalog` block from the nearest `terragrunt.hcl` file in the parent directories. // // We want users to be able to browse to any folder in an `infra-live` repo, run `terragrunt catalog` (with no URL) arg. // ReadCatalogConfig looks for the "nearest" `terragrunt.hcl` in the parent directories if the given // `opts.TerragruntConfigPath` does not exist. Since our normal parsing `ParseConfig` does not always work, // as some `terragrunt.hcl` files are meant to be used from an `include` and/or they might use // `find_in_parent_folders` and they only work from certain child folders, it parses this file to see if the // config contains `include{...find_in_parent_folders()...}` block to determine if it is the root configuration. // If it finds `terragrunt.hcl` that already has `include`, then read that configuration as is, // otherwise generate a stub child `terragrunt.hcl` in memory with an `include` to pull in the one we found. // Unlike the "ReadTerragruntConfig" func, it ignores any configuration errors not related to the "catalog" block. func ReadCatalogConfig(parentCtx context.Context, l log.Logger, pctx *ParsingContext) (*CatalogConfig, error) { configPath, configString, err := findCatalogConfig(parentCtx, l, pctx) if err != nil || configPath == "" { return nil, err } pctx = pctx.Clone() pctx.TerragruntConfigPath = configPath pctx.ParserOptions = append(pctx.ParserOptions, hclparse.WithHaltOnErrorOnlyForBlocks([]string{MetadataCatalog})) pctx.ConvertToTerragruntConfigFunc = convertToTerragruntCatalogConfig config, err := ParseConfigString(parentCtx, pctx, l, configPath, configString, nil) if err != nil { return nil, err } return config.Catalog, nil } func findCatalogConfig(ctx context.Context, l log.Logger, outerPctx *ParsingContext) (string, string, error) { var ( configPath = filepath.Join(filepath.Dir(outerPctx.TerragruntConfigPath), outerPctx.ScaffoldRootFileName) configName = outerPctx.ScaffoldRootFileName catalogConfigPath string ) for { // This allows to stop the process by pressing Ctrl-C, in case the loop is endless, // it can happen if the functions of the `filepath` package do not work correctly under a certain operating system. select { case <-ctx.Done(): return "", "", nil default: // continue } parseCtx, pctx := NewParsingContext(ctx, l, WithStrictControls(outerPctx.StrictControls)) pctx.TerragruntConfigPath = filepath.Join(filepath.Dir(configPath), util.UniqueID(), configName) pctx.MaxFoldersToCheck = outerPctx.MaxFoldersToCheck newConfigPath, err := FindInParentFolders(parseCtx, pctx, l, []string{configName}) if err != nil { var parentFileNotFoundError ParentFileNotFoundError if ok := errors.As(err, &parentFileNotFoundError); ok { break } return "", "", err } configString, err := util.ReadFileAsString(newConfigPath) if err != nil { return "", "", err } // if the config contains `include` block (root config), read the config as is. if includeBlockReg.MatchString(configString) { return newConfigPath, configString, nil } // if the config contains a `catalog` block, save the path in case the root config is not found if catalogBlockReg.MatchString(configString) { catalogConfigPath = newConfigPath } configPath = filepath.Dir(newConfigPath) } // if the config with the `catalog` block is found, create the root config with `include{ find_in_parent_folders() }` // and the path one directory deeper in order for `find_in_parent_folders` can find the catalog configuration. if catalogConfigPath != "" { configString := fmt.Sprintf(rootConfigFmt, configName) configPath = filepath.Join(filepath.Dir(catalogConfigPath), util.UniqueID(), configName) return configPath, configString, nil } return "", "", nil } func convertToTerragruntCatalogConfig(ctx context.Context, pctx *ParsingContext, configPath string, terragruntConfigFromFile *terragruntConfigFile) (cfg *TerragruntConfig, err error) { var ( terragruntConfig = &TerragruntConfig{} defaultMetadata = map[string]any{FoundInFile: configPath} ) if terragruntConfigFromFile.Catalog != nil { terragruntConfig.Catalog = terragruntConfigFromFile.Catalog terragruntConfig.Catalog.normalize(configPath) terragruntConfig.SetFieldMetadata(MetadataCatalog, defaultMetadata) } if terragruntConfigFromFile.Engine != nil { terragruntConfig.Engine = terragruntConfigFromFile.Engine terragruntConfig.SetFieldMetadata(MetadataEngine, defaultMetadata) } if terragruntConfigFromFile.Exclude != nil { terragruntConfig.Exclude = terragruntConfigFromFile.Exclude terragruntConfig.SetFieldMetadata(MetadataExclude, defaultMetadata) } if terragruntConfigFromFile.Errors != nil { terragruntConfig.Errors = terragruntConfigFromFile.Errors terragruntConfig.SetFieldMetadata(MetadataErrors, defaultMetadata) } if pctx.Locals != nil && *pctx.Locals != cty.NilVal { // we should ignore any errors from `parseCtyValueToMap` as some `locals` values might have been incorrectly evaluated, that results to `json.Unmarshal` error. // for example if the locals block looks like `{"var1":, "var2":"value2"}`, `parseCtyValueToMap` returns the map with "var2" value and an syntax error, // but since we consciously understand that not all variables can be evaluated correctly due to the fact that parsing may not start from the real root file, we can safely ignore this error. localsParsed, _ := ctyhelper.ParseCtyValueToMap(*pctx.Locals) // Only set Locals if there are actual values to avoid setting an empty map if len(localsParsed) > 0 { terragruntConfig.Locals = localsParsed terragruntConfig.SetFieldMetadataMap(MetadataLocals, localsParsed, defaultMetadata) } } return terragruntConfig, nil } ================================================ FILE: pkg/config/catalog_test.go ================================================ package config_test import ( "fmt" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCatalogParseConfigFile(t *testing.T) { t.Parallel() basePath, err := filepath.Abs(filepath.Join("..", "..", "test", "fixtures", "catalog")) require.NoError(t, err) testCases := []struct { expectedErr error expectedConfig *config.CatalogConfig configPath string }{ { configPath: filepath.Join(basePath, "config1.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "terraform-aws-eks"), // this path exists in the fixture directory and must be converted to the absolute path. "/repo-copier", "./terraform-aws-service-catalog", "/project/terragrunt/test/terraform-aws-vpc", "github.com/gruntwork-io/terraform-aws-lambda", }, }, }, { configPath: filepath.Join(basePath, "config2.hcl"), }, { configPath: filepath.Join(basePath, "config3.hcl"), expectedConfig: &config.CatalogConfig{}, }, { configPath: filepath.Join(basePath, "complex-legacy-root/terragrunt.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex-legacy-root/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex/root.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex-legacy-root/dev/terragrunt.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex-legacy-root/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex/dev/root.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex/dev/us-west-1/terragrunt.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex/dev/us-west-1/modules/terragrunt.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "complex/prod/terragrunt.hcl"), expectedConfig: &config.CatalogConfig{ URLs: []string{ filepath.Join(basePath, "complex/dev/us-west-1/modules/terraform-aws-eks"), "./terraform-aws-service-catalog", "https://github.com/gruntwork-io/terraform-aws-utilities", }, }, }, { configPath: filepath.Join(basePath, "config4.hcl"), expectedConfig: &config.CatalogConfig{ DefaultTemplate: "/test/fixtures/scaffold/external-template", }, }, } for i, tt := range testCases { t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() l := logger.CreateLogger() _, catalogPctx := newTestParsingContext(t, tt.configPath) catalogPctx.ScaffoldRootFileName = filepath.Base(tt.configPath) config, err := config.ReadCatalogConfig(t.Context(), l, catalogPctx) if tt.expectedErr == nil { require.NoError(t, err) assert.Equal(t, tt.expectedConfig, config) } else { assert.EqualError(t, err, tt.expectedErr.Error()) } }) } } ================================================ FILE: pkg/config/config.go ================================================ // Package config provides functionality for parsing Terragrunt configuration files. package config import ( "context" "encoding/json" "fmt" "io" "io/fs" "net/url" "os" "path" "path/filepath" "regexp" "slices" "strconv" "strings" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/writer" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/hashicorp/go-getter" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" "maps" "github.com/gruntwork-io/go-commons/files" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/mitchellh/mapstructure" ) const ( DefaultTerragruntConfigPath = "terragrunt.hcl" DefaultStackFile = "terragrunt.stack.hcl" DefaultTerragruntJSONConfigPath = "terragrunt.hcl.json" RecommendedParentConfigName = "root.hcl" FoundInFile = "found_in_file" iamRoleCacheName = "iamRoleCache" logMsgSeparator = "\n" DefaultEngineType = "rpc" MetadataTerraform = "terraform" MetadataTerraformBinary = "terraform_binary" MetadataTerraformVersionConstraint = "terraform_version_constraint" MetadataTerragruntVersionConstraint = "terragrunt_version_constraint" MetadataRemoteState = "remote_state" MetadataDependencies = "dependencies" MetadataDependency = "dependency" MetadataDownloadDir = "download_dir" MetadataPreventDestroy = "prevent_destroy" MetadataIamRole = "iam_role" MetadataIamAssumeRoleDuration = "iam_assume_role_duration" MetadataIamAssumeRoleSessionName = "iam_assume_role_session_name" MetadataIamWebIdentityToken = "iam_web_identity_token" MetadataInputs = "inputs" MetadataLocals = "locals" MetadataLocal = "local" MetadataCatalog = "catalog" MetadataEngine = "engine" MetadataGenerateConfigs = "generate" MetadataInclude = "include" MetadataFeatureFlag = "feature" MetadataExclude = "exclude" MetadataErrors = "errors" MetadataRetry = "retry" MetadataIgnore = "ignore" MetadataValues = "values" MetadataStack = "stack" MetadataUnit = "unit" ) var ( // Order matters, for example if none of the files are found `GetDefaultConfigPath` func returns the last element. DefaultTerragruntConfigPaths = []string{ DefaultTerragruntJSONConfigPath, DefaultTerragruntConfigPath, } DefaultParserOptions = func(l log.Logger, strictControls strict.Controls) []hclparse.Option { writer := writer.New( writer.WithLogger(l), writer.WithDefaultLevel(log.ErrorLevel), writer.WithMsgSeparator(logMsgSeparator), ) parseOpts := make([]hclparse.Option, 0, 3) //nolint:mnd parseOpts = append(parseOpts, hclparse.WithDiagnosticsWriter(writer, l.Formatter().DisabledColors()), hclparse.WithLogger(l), ) strictControl := strictControls.Find(controls.BareInclude) // If we can't find the strict control, we're probably in a test // where the option is being hand written. In that case, // we'll assume we're not in strict mode. if strictControl != nil { strictControl.SuppressWarning() if err := strictControl.Evaluate(context.Background()); err != nil { return parseOpts } } parseOpts = append(parseOpts, hclparse.WithFileUpdate(updateBareIncludeBlock)) return parseOpts } DefaultGenerateBlockIfDisabledValueStr = codegen.DisabledSkipStr ) // DecodedBaseBlocks decoded base blocks struct type DecodedBaseBlocks struct { TrackInclude *TrackInclude Locals *cty.Value FeatureFlags *cty.Value } // TerragruntConfig represents a parsed and expanded configuration // NOTE: if any attributes are added, make sure to update terragruntConfigAsCty in config_as_cty.go type TerragruntConfig struct { Locals map[string]any ProcessedIncludes IncludeConfigsMap FieldsMetadata map[string]map[string]any Terraform *TerraformConfig Errors *ErrorsConfig RemoteState *remotestate.RemoteState Dependencies *ModuleDependencies Exclude *ExcludeConfig PreventDestroy *bool GenerateConfigs map[string]codegen.GenerateConfig IamAssumeRoleDuration *int64 Inputs map[string]any Engine *EngineConfig Catalog *CatalogConfig IamWebIdentityToken string IamAssumeRoleSessionName string IamRole string DownloadDir string TerragruntVersionConstraint string TerraformVersionConstraint string TerraformBinary string TerragruntDependencies Dependencies FeatureFlags FeatureFlags IsPartial bool } func (cfg *TerragruntConfig) GetRemoteState(l log.Logger, pctx *ParsingContext) (*remotestate.RemoteState, error) { if cfg.RemoteState == nil { l.Debug("Did not find remote `remote_state` block in the config") return nil, nil } sourceURL, err := GetTerraformSourceURL(pctx.Source, pctx.SourceMap, pctx.OriginalTerragruntConfigPath, cfg) if err != nil { return nil, err } if sourceURL != "" { walkWithSymlinks := pctx.Experiments.Evaluate(experiment.Symlinks) tfSource, err := tf.NewSource(l, sourceURL, pctx.DownloadDir, pctx.WorkingDir, walkWithSymlinks) if err != nil { return nil, err } pctx.WorkingDir = tfSource.WorkingDir } return cfg.RemoteState, nil } func (cfg *TerragruntConfig) String() string { return fmt.Sprintf("TerragruntConfig{Terraform = %v, RemoteState = %v, Dependencies = %v, PreventDestroy = %v}", cfg.Terraform, cfg.RemoteState, cfg.Dependencies, cfg.PreventDestroy) } // GetIAMRoleOptions is a helper function that converts the Terragrunt config IAM role attributes to // iam.RoleOptions struct. func (cfg *TerragruntConfig) GetIAMRoleOptions() iam.RoleOptions { configIAMRoleOptions := iam.RoleOptions{ RoleARN: cfg.IamRole, AssumeRoleSessionName: cfg.IamAssumeRoleSessionName, WebIdentityToken: cfg.IamWebIdentityToken, } if cfg.IamAssumeRoleDuration != nil { configIAMRoleOptions.AssumeRoleDuration = *cfg.IamAssumeRoleDuration } return configIAMRoleOptions } // WriteTo writes the terragrunt config to a writer func (cfg *TerragruntConfig) WriteTo(w io.Writer) (int64, error) { cfgAsCty, err := TerragruntConfigAsCty(cfg) if err != nil { return 0, err } f := hclwrite.NewFile() rootBody := f.Body() // Handle blocks first if len(cfg.Locals) > 0 { localsBlock := hclwrite.NewBlock("locals", nil) localsBody := localsBlock.Body() localsAsCty := cfgAsCty.GetAttr("locals") for k := range cfg.Locals { localsBody.SetAttributeValue(k, localsAsCty.GetAttr(k)) } rootBody.AppendBlock(localsBlock) } if cfg.Terraform != nil { terraformBlock := hclwrite.NewBlock("terraform", nil) terraformBody := terraformBlock.Body() terraformAsCty := cfgAsCty.GetAttr("terraform") // Handle source if cfg.Terraform.Source != nil { terraformBody.SetAttributeValue("source", terraformAsCty.GetAttr("source")) } // Handle extra_arguments blocks if len(cfg.Terraform.ExtraArgs) > 0 { extraArgsAsCty := terraformAsCty.GetAttr("extra_arguments").AsValueMap() for _, arg := range cfg.Terraform.ExtraArgs { extraArgBlock := hclwrite.NewBlock("extra_arguments", []string{arg.Name}) extraArgBody := extraArgBlock.Body() argCty := extraArgsAsCty[arg.Name] if arg.Commands != nil { extraArgBody.SetAttributeValue("commands", argCty.GetAttr("commands")) } if arg.Arguments != nil { extraArgBody.SetAttributeValue("arguments", argCty.GetAttr("arguments")) } if arg.RequiredVarFiles != nil { extraArgBody.SetAttributeValue("required_var_files", argCty.GetAttr("required_var_files")) } if arg.OptionalVarFiles != nil { extraArgBody.SetAttributeValue("optional_var_files", argCty.GetAttr("optional_var_files")) } if arg.EnvVars != nil { extraArgBody.SetAttributeValue("env_vars", argCty.GetAttr("env_vars")) } terraformBody.AppendBlock(extraArgBlock) } } // Handle hooks for _, beforeHook := range cfg.Terraform.BeforeHooks { //nolint:dupl beforeHookBlock := hclwrite.NewBlock("before_hook", []string{beforeHook.Name}) beforeHookBody := beforeHookBlock.Body() beforeHookAsCty := terraformAsCty.GetAttr("before_hook").AsValueMap()[beforeHook.Name] if beforeHook.If != nil { beforeHookBody.SetAttributeValue("if", beforeHookAsCty.GetAttr("if")) } if beforeHook.RunOnError != nil { beforeHookBody.SetAttributeValue("run_on_error", beforeHookAsCty.GetAttr("run_on_error")) } beforeHookBody.SetAttributeValue("commands", beforeHookAsCty.GetAttr("commands")) beforeHookBody.SetAttributeValue("execute", beforeHookAsCty.GetAttr("execute")) if beforeHook.WorkingDir != nil { beforeHookBody.SetAttributeValue("working_dir", beforeHookAsCty.GetAttr("working_dir")) } terraformBody.AppendBlock(beforeHookBlock) } for _, afterHook := range cfg.Terraform.AfterHooks { //nolint:dupl afterHookBlock := hclwrite.NewBlock("after_hook", []string{afterHook.Name}) afterHookBody := afterHookBlock.Body() afterHookAsCty := terraformAsCty.GetAttr("after_hook").AsValueMap()[afterHook.Name] if afterHook.If != nil { afterHookBody.SetAttributeValue("if", afterHookAsCty.GetAttr("if")) } if afterHook.RunOnError != nil { afterHookBody.SetAttributeValue("run_on_error", afterHookAsCty.GetAttr("run_on_error")) } afterHookBody.SetAttributeValue("commands", afterHookAsCty.GetAttr("commands")) afterHookBody.SetAttributeValue("execute", afterHookAsCty.GetAttr("execute")) if afterHook.WorkingDir != nil { afterHookBody.SetAttributeValue("working_dir", afterHookAsCty.GetAttr("working_dir")) } terraformBody.AppendBlock(afterHookBlock) } for _, errorHook := range cfg.Terraform.ErrorHooks { errorHookBlock := hclwrite.NewBlock("error_hook", []string{errorHook.Name}) errorHookBody := errorHookBlock.Body() errorHookAsCty := terraformAsCty.GetAttr("error_hook").AsValueMap()[errorHook.Name] errorHookBody.SetAttributeValue("commands", errorHookAsCty.GetAttr("commands")) errorHookBody.SetAttributeValue("execute", errorHookAsCty.GetAttr("execute")) errorHookBody.SetAttributeValue("on_errors", errorHookAsCty.GetAttr("on_errors")) if errorHook.WorkingDir != nil { errorHookBody.SetAttributeValue("working_dir", errorHookAsCty.GetAttr("working_dir")) } terraformBody.AppendBlock(errorHookBlock) } rootBody.AppendBlock(terraformBlock) } if cfg.RemoteState != nil { remoteStateBlock := hclwrite.NewBlock("remote_state", nil) remoteStateBody := remoteStateBlock.Body() remoteStateAsCty := cfgAsCty.GetAttr("remote_state") remoteStateBody.SetAttributeValue("backend", remoteStateAsCty.GetAttr("backend")) if cfg.RemoteState.DisableInit { remoteStateBody.SetAttributeValue("disable_init", remoteStateAsCty.GetAttr("disable_init")) } if cfg.RemoteState.DisableDependencyOptimization { remoteStateBody.SetAttributeValue("disable_dependency_optimization", remoteStateAsCty.GetAttr("disable_dependency_optimization")) } if cfg.RemoteState.BackendConfig != nil { remoteStateBody.SetAttributeValue("config", remoteStateAsCty.GetAttr("config")) } rootBody.AppendBlock(remoteStateBlock) } if cfg.Dependencies != nil && len(cfg.Dependencies.Paths) > 0 { dependenciesBlock := hclwrite.NewBlock("dependencies", nil) dependenciesBody := dependenciesBlock.Body() dependenciesAsCty := cfgAsCty.GetAttr("dependencies") dependenciesBody.SetAttributeValue("paths", dependenciesAsCty.GetAttr("paths")) rootBody.AppendBlock(dependenciesBlock) } // Handle dependency blocks for _, dep := range cfg.TerragruntDependencies { depBlock := hclwrite.NewBlock("dependency", []string{dep.Name}) depBody := depBlock.Body() depAsCty := cfgAsCty.GetAttr("dependency").GetAttr(dep.Name) depBody.SetAttributeValue("config_path", depAsCty.GetAttr("config_path")) if dep.Enabled != nil { depBody.SetAttributeValue("enabled", goboolToCty(*dep.Enabled)) } if dep.SkipOutputs != nil { depBody.SetAttributeValue("skip_outputs", goboolToCty(*dep.SkipOutputs)) } if dep.MockOutputs != nil { depBody.SetAttributeValue("mock_outputs", depAsCty.GetAttr("mock_outputs")) } if dep.MockOutputsAllowedTerraformCommands != nil { depBody.SetAttributeValue("mock_outputs_allowed_terraform_commands", depAsCty.GetAttr("mock_outputs_allowed_terraform_commands")) } if dep.MockOutputsMergeStrategyWithState != nil { depBody.SetAttributeValue("mock_outputs_merge_strategy_with_state", depAsCty.GetAttr("mock_outputs_merge_strategy_with_state")) } rootBody.AppendBlock(depBlock) } // Handle generate blocks for name, gen := range cfg.GenerateConfigs { genBlock := hclwrite.NewBlock("generate", []string{name}) genBody := genBlock.Body() genBody.SetAttributeValue("path", gostringToCty(gen.Path)) genBody.SetAttributeValue("if_exists", gostringToCty(gen.IfExistsStr)) genBody.SetAttributeValue("if_disabled", gostringToCty(gen.IfDisabledStr)) genBody.SetAttributeValue("contents", gostringToCty(gen.Contents)) if gen.CommentPrefix != codegen.DefaultCommentPrefix { genBody.SetAttributeValue("comment_prefix", gostringToCty(gen.CommentPrefix)) } if gen.DisableSignature { genBody.SetAttributeValue("disable_signature", goboolToCty(gen.DisableSignature)) } if gen.Disable { genBody.SetAttributeValue("disable", goboolToCty(gen.Disable)) } rootBody.AppendBlock(genBlock) } // Handle feature flags for _, flag := range cfg.FeatureFlags { flagBlock := hclwrite.NewBlock("feature", []string{flag.Name}) flagBody := flagBlock.Body() flagAsCty := cfgAsCty.GetAttr("feature").GetAttr(flag.Name) if flag.Default != nil { flagBody.SetAttributeValue("default", flagAsCty.GetAttr("default")) } rootBody.AppendBlock(flagBlock) } // Handle engine block if cfg.Engine != nil { engineBlock := hclwrite.NewBlock("engine", nil) engineBody := engineBlock.Body() engineAsCty := cfgAsCty.GetAttr("engine") if cfg.Engine.Source != "" { engineBody.SetAttributeValue("source", engineAsCty.GetAttr("source")) } if cfg.Engine.Version != nil { engineBody.SetAttributeValue("version", engineAsCty.GetAttr("version")) } if cfg.Engine.Type != nil { engineBody.SetAttributeValue("type", engineAsCty.GetAttr("type")) } if cfg.Engine.Meta != nil { engineBody.SetAttributeValue("meta", engineAsCty.GetAttr("meta")) } rootBody.AppendBlock(engineBlock) } // Handle exclude block if cfg.Exclude != nil { excludeBlock := hclwrite.NewBlock("exclude", nil) excludeBody := excludeBlock.Body() excludeAsCty := cfgAsCty.GetAttr("exclude") if cfg.Exclude.ExcludeDependencies != nil { excludeBody.SetAttributeValue("exclude_dependencies", excludeAsCty.GetAttr("exclude_dependencies")) } if len(cfg.Exclude.Actions) > 0 { excludeBody.SetAttributeValue("actions", excludeAsCty.GetAttr("actions")) } if cfg.Exclude.NoRun != nil { excludeBody.SetAttributeValue("no_run", excludeAsCty.GetAttr("no_run")) } excludeBody.SetAttributeValue("if", excludeAsCty.GetAttr("if")) rootBody.AppendBlock(excludeBlock) } // Handle errors block if cfg.Errors != nil { errorsBlock := hclwrite.NewBlock("errors", nil) errorsBody := errorsBlock.Body() // Handle retry blocks if len(cfg.Errors.Retry) > 0 { for _, retryConfig := range cfg.Errors.Retry { retryBlock := hclwrite.NewBlock("retry", []string{retryConfig.Label}) retryBody := retryBlock.Body() if retryConfig.MaxAttempts > 0 { retryBody.SetAttributeValue("max_attempts", cty.NumberIntVal(int64(retryConfig.MaxAttempts))) } if retryConfig.SleepIntervalSec > 0 { retryBody.SetAttributeValue("sleep_interval_sec", cty.NumberIntVal(int64(retryConfig.SleepIntervalSec))) } if len(retryConfig.RetryableErrors) > 0 { retryableErrors := make([]cty.Value, len(retryConfig.RetryableErrors)) for i, err := range retryConfig.RetryableErrors { retryableErrors[i] = cty.StringVal(err) } retryBody.SetAttributeValue("retryable_errors", cty.ListVal(retryableErrors)) } errorsBody.AppendBlock(retryBlock) } } // Handle ignore blocks if len(cfg.Errors.Ignore) > 0 { for _, ignoreConfig := range cfg.Errors.Ignore { ignoreBlock := hclwrite.NewBlock("ignore", []string{ignoreConfig.Label}) ignoreBody := ignoreBlock.Body() if len(ignoreConfig.IgnorableErrors) > 0 { ignorableErrors := make([]cty.Value, len(ignoreConfig.IgnorableErrors)) for i, err := range ignoreConfig.IgnorableErrors { ignorableErrors[i] = cty.StringVal(err) } ignoreBody.SetAttributeValue("ignorable_errors", cty.ListVal(ignorableErrors)) } if ignoreConfig.Message != "" { ignoreBody.SetAttributeValue("message", cty.StringVal(ignoreConfig.Message)) } if ignoreConfig.Signals != nil { ignoreBody.SetAttributeValue("signals", cty.MapVal(ignoreConfig.Signals)) } errorsBody.AppendBlock(ignoreBlock) } } rootBody.AppendBlock(errorsBlock) } // Handle catalog block if cfg.Catalog != nil { catalogBlock := hclwrite.NewBlock("catalog", nil) catalogBody := catalogBlock.Body() catalogAsCty := cfgAsCty.GetAttr("catalog") if cfg.Catalog.DefaultTemplate != "" { catalogBody.SetAttributeValue("default_template", catalogAsCty.GetAttr("default_template")) } if len(cfg.Catalog.URLs) > 0 { catalogBody.SetAttributeValue("urls", catalogAsCty.GetAttr("urls")) } if cfg.Catalog.NoShell != nil { catalogBody.SetAttributeValue("no_shell", catalogAsCty.GetAttr("no_shell")) } if cfg.Catalog.NoHooks != nil { catalogBody.SetAttributeValue("no_hooks", catalogAsCty.GetAttr("no_hooks")) } rootBody.AppendBlock(catalogBlock) } // Handle attributes if cfg.TerraformBinary != "" { rootBody.SetAttributeValue("terraform_binary", cfgAsCty.GetAttr("terraform_binary")) } if cfg.TerraformVersionConstraint != "" { rootBody.SetAttributeValue("terraform_version_constraint", cfgAsCty.GetAttr("terraform_version_constraint")) } if cfg.TerragruntVersionConstraint != "" { rootBody.SetAttributeValue("terragrunt_version_constraint", cfgAsCty.GetAttr("terragrunt_version_constraint")) } if cfg.DownloadDir != "" { rootBody.SetAttributeValue("download_dir", cfgAsCty.GetAttr("download_dir")) } if cfg.PreventDestroy != nil { rootBody.SetAttributeValue("prevent_destroy", cfgAsCty.GetAttr("prevent_destroy")) } if cfg.IamRole != "" { rootBody.SetAttributeValue("iam_role", cfgAsCty.GetAttr("iam_role")) } if cfg.IamAssumeRoleDuration != nil { rootBody.SetAttributeValue("iam_assume_role_duration", cfgAsCty.GetAttr("iam_assume_role_duration")) } if cfg.IamAssumeRoleSessionName != "" { rootBody.SetAttributeValue("iam_assume_role_session_name", cfgAsCty.GetAttr("iam_assume_role_session_name")) } if len(cfg.Inputs) > 0 { rootBody.SetAttributeValue("inputs", cfgAsCty.GetAttr("inputs")) } return f.WriteTo(w) } // terragruntConfigFile represents the configuration supported in a Terragrunt configuration file (i.e. // terragrunt.hcl) type terragruntConfigFile struct { Catalog *CatalogConfig `hcl:"catalog,block"` Engine *EngineConfig `hcl:"engine,block"` Terraform *TerraformConfig `hcl:"terraform,block"` TerraformBinary *string `hcl:"terraform_binary,attr"` TerraformVersionConstraint *string `hcl:"terraform_version_constraint,attr"` TerragruntVersionConstraint *string `hcl:"terragrunt_version_constraint,attr"` Inputs *cty.Value `hcl:"inputs,attr"` // We allow users to configure remote state (backend) via blocks: // // remote_state { // backend = "s3" // config = { ... } // } // // Or as attributes: // // remote_state = { // backend = "s3" // config = { ... } // } RemoteState *remotestate.ConfigFile `hcl:"remote_state,block"` RemoteStateAttr *cty.Value `hcl:"remote_state,optional"` Dependencies *ModuleDependencies `hcl:"dependencies,block"` DownloadDir *string `hcl:"download_dir,attr"` PreventDestroy *bool `hcl:"prevent_destroy,attr"` IamRole *string `hcl:"iam_role,attr"` IamAssumeRoleDuration *int64 `hcl:"iam_assume_role_duration,attr"` IamAssumeRoleSessionName *string `hcl:"iam_assume_role_session_name,attr"` IamWebIdentityToken *string `hcl:"iam_web_identity_token,attr"` TerragruntDependencies []Dependency `hcl:"dependency,block"` FeatureFlags []*FeatureFlag `hcl:"feature,block"` Exclude *ExcludeConfig `hcl:"exclude,block"` Errors *ErrorsConfig `hcl:"errors,block"` // We allow users to configure code generation via blocks: // // generate "example" { // path = "example.tf" // contents = "example" // } // // Or via attributes: // // generate = { // example = { // path = "example.tf" // contents = "example" // } // } GenerateAttrs *cty.Value `hcl:"generate,optional"` GenerateBlocks []terragruntGenerateBlock `hcl:"generate,block"` // This struct is used for validating and parsing the entire terragrunt config. Since locals and include are // evaluated in a completely separate cycle, it should not be evaluated here. Otherwise, we can't support self // referencing other elements in the same block. // We don't want to use the special Remain keyword here, as that would cause the checker to support parsing config // that have extraneous, unsupported blocks and attributes. Locals *terragruntLocal `hcl:"locals,block"` Include []terragruntIncludeIgnore `hcl:"include,block"` } // We use a struct designed to not parse the block, as locals and includes are parsed and decoded using a special // routine that allows references to the other locals in the same block. type terragruntLocal struct { Remain hcl.Body `hcl:",remain"` } type terragruntIncludeIgnore struct { Remain hcl.Body `hcl:",remain"` Name string `hcl:"name,label"` } // Struct used to parse generate blocks. This will later be converted to GenerateConfig structs so that we can go // through the codegen routine. type terragruntGenerateBlock struct { IfDisabled *string `hcl:"if_disabled,attr" mapstructure:"if_disabled"` CommentPrefix *string `hcl:"comment_prefix,attr" mapstructure:"comment_prefix"` DisableSignature *bool `hcl:"disable_signature,attr" mapstructure:"disable_signature"` Disable *bool `hcl:"disable,attr" mapstructure:"disable"` Name string `hcl:",label" mapstructure:",omitempty"` Path string `hcl:"path,attr" mapstructure:"path"` IfExists string `hcl:"if_exists,attr" mapstructure:"if_exists"` Contents string `hcl:"contents,attr" mapstructure:"contents"` } type IncludeConfigsMap map[string]IncludeConfig // ContainsPath returns true if the given path is contained in at least one configuration. func (cfgs IncludeConfigsMap) ContainsPath(path string) bool { for _, cfg := range cfgs { if cfg.Path == path { return true } } return false } type IncludeConfigs []IncludeConfig // IncludeConfig represents the configuration settings for a parent Terragrunt configuration file that you can // include into a child Terragrunt configuration file. You can have more than one include config. type IncludeConfig struct { Expose *bool `hcl:"expose,attr"` MergeStrategy *string `hcl:"merge_strategy,attr"` Name string `hcl:"name,label"` Path string `hcl:"path,attr"` } func (include *IncludeConfig) String() string { if include == nil { return "IncludeConfig{nil}" } exposeStr := "nil" if include.Expose != nil { exposeStr = strconv.FormatBool(*include.Expose) } mergeStrategyStr := "nil" if include.MergeStrategy != nil { mergeStrategyStr = fmt.Sprintf("%v", include.MergeStrategy) } return fmt.Sprintf("IncludeConfig{Path = %v, Expose = %v, MergeStrategy = %v}", include.Path, exposeStr, mergeStrategyStr) } func (include *IncludeConfig) GetExpose() bool { if include == nil || include.Expose == nil { return false } return *include.Expose } func (include *IncludeConfig) GetMergeStrategy() (MergeStrategyType, error) { if include.MergeStrategy == nil { return ShallowMerge, nil } strategy := *include.MergeStrategy switch strategy { case string(NoMerge): return NoMerge, nil case string(ShallowMerge): return ShallowMerge, nil case string(DeepMerge): return DeepMerge, nil case string(DeepMergeMapOnly): return DeepMergeMapOnly, nil default: return NoMerge, errors.New(InvalidMergeStrategyTypeError(strategy)) } } type MergeStrategyType string const ( NoMerge MergeStrategyType = "no_merge" ShallowMerge MergeStrategyType = "shallow" DeepMerge MergeStrategyType = "deep" DeepMergeMapOnly MergeStrategyType = "deep_map_only" ) // ModuleDependencies represents the paths to other Terraform modules that must be applied before the current module // can be applied type ModuleDependencies struct { Paths []string `hcl:"paths,attr" cty:"paths"` } // Merge appends the paths in the provided ModuleDependencies object into this ModuleDependencies object. func (deps *ModuleDependencies) Merge(source *ModuleDependencies) { if source == nil { return } for _, path := range source.Paths { if !slices.Contains(deps.Paths, path) { deps.Paths = append(deps.Paths, path) } } } func (deps *ModuleDependencies) String() string { return fmt.Sprintf("ModuleDependencies{Paths = %v}", deps.Paths) } // Hook specifies terraform commands (apply/plan) and array of os commands to execute type Hook struct { If *bool `hcl:"if,attr" cty:"if"` RunOnError *bool `hcl:"run_on_error,attr" cty:"run_on_error"` SuppressStdout *bool `hcl:"suppress_stdout,attr" cty:"suppress_stdout"` WorkingDir *string `hcl:"working_dir,attr" cty:"working_dir"` Name string `hcl:"name,label" cty:"name"` Commands []string `hcl:"commands,attr" cty:"commands"` Execute []string `hcl:"execute,attr" cty:"execute"` } type ErrorHook struct { SuppressStdout *bool `hcl:"suppress_stdout,attr" cty:"suppress_stdout"` WorkingDir *string `hcl:"working_dir,attr" cty:"working_dir"` Name string `hcl:"name,label" cty:"name"` Commands []string `hcl:"commands,attr" cty:"commands"` Execute []string `hcl:"execute,attr" cty:"execute"` OnErrors []string `hcl:"on_errors,attr" cty:"on_errors"` } func (conf *Hook) String() string { return fmt.Sprintf("Hook{Name = %s, Commands = %v}", conf.Name, len(conf.Commands)) } func (conf *ErrorHook) String() string { return fmt.Sprintf("Hook{Name = %s, Commands = %v}", conf.Name, len(conf.Commands)) } // TerraformConfig specifies where to find the Terraform configuration files // NOTE: If any attributes or blocks are added here, be sure to add it to ctyTerraformConfig in config_as_cty.go as // well. type TerraformConfig struct { Source *string `hcl:"source,attr"` // Ideally we can avoid the pointer to list slice, but if it is not a pointer, Terraform requires the attribute to // be defined and we want to make this optional. IncludeInCopy *[]string `hcl:"include_in_copy,attr"` ExcludeFromCopy *[]string `hcl:"exclude_from_copy,attr"` CopyTerraformLockFile *bool `hcl:"copy_terraform_lock_file,attr"` ExtraArgs []TerraformExtraArguments `hcl:"extra_arguments,block"` BeforeHooks []Hook `hcl:"before_hook,block"` AfterHooks []Hook `hcl:"after_hook,block"` ErrorHooks []ErrorHook `hcl:"error_hook,block"` } func (cfg *TerraformConfig) String() string { return fmt.Sprintf("TerraformConfig{Source = %v}", cfg.Source) } func (cfg *TerraformConfig) GetBeforeHooks() []Hook { if cfg == nil { return nil } return cfg.BeforeHooks } func (cfg *TerraformConfig) GetAfterHooks() []Hook { if cfg == nil { return nil } return cfg.AfterHooks } func (cfg *TerraformConfig) GetErrorHooks() []ErrorHook { if cfg == nil { return nil } return cfg.ErrorHooks } func (cfg *TerraformConfig) ValidateHooks() error { beforeAndAfterHooks := append(cfg.GetBeforeHooks(), cfg.GetAfterHooks()...) for _, curHook := range beforeAndAfterHooks { if len(curHook.Execute) < 1 || curHook.Execute[0] == "" { return InvalidArgError(fmt.Sprintf("Error with hook %s. Need at least one non-empty argument in 'execute'.", curHook.Name)) } } for _, curHook := range cfg.GetErrorHooks() { if len(curHook.Execute) < 1 || curHook.Execute[0] == "" { return InvalidArgError(fmt.Sprintf("Error with hook %s. Need at least one non-empty argument in 'execute'.", curHook.Name)) } } return nil } // TerraformExtraArguments sets a list of arguments to pass to Terraform if command fits any in the `Commands` list type TerraformExtraArguments struct { Arguments *[]string `hcl:"arguments,attr" cty:"arguments"` RequiredVarFiles *[]string `hcl:"required_var_files,attr" cty:"required_var_files"` OptionalVarFiles *[]string `hcl:"optional_var_files,attr" cty:"optional_var_files"` EnvVars *map[string]string `hcl:"env_vars,attr" cty:"env_vars"` Name string `hcl:"name,label" cty:"name"` Commands []string `hcl:"commands,attr" cty:"commands"` } func (args *TerraformExtraArguments) String() string { return fmt.Sprintf( "TerraformArguments{Name = %s, Arguments = %v, Commands = %v, EnvVars = %v}", args.Name, args.Arguments, args.Commands, args.EnvVars) } func (args *TerraformExtraArguments) GetVarFiles(l log.Logger) []string { var varFiles []string // Include all specified RequiredVarFiles. if args.RequiredVarFiles != nil { varFiles = append(varFiles, util.RemoveDuplicatesKeepLast(*args.RequiredVarFiles)...) } // If OptionalVarFiles is specified, check for each file if it exists and if so, include in the var // files list. Note that it is possible that many files resolve to the same path, so we remove // duplicates. if args.OptionalVarFiles != nil { for _, file := range util.RemoveDuplicatesKeepLast(*args.OptionalVarFiles) { if util.FileExists(file) { varFiles = append(varFiles, file) } else { l.Debugf("Skipping var-file %s as it does not exist", file) } } } return varFiles } // GetTerraformSourceURL returns the source URL for OpenTofu/Terraform configuration. // // There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific // URL: via a command-line option or via an entry in the Terragrunt configuration. If the user used one of these, this // method returns the source URL. If neither is specified, returns "." to indicate the current directory should be // used as the source, ensuring a .terragrunt-cache directory is always created for consistency. func GetTerraformSourceURL(source string, sourceMap map[string]string, originalConfigPath string, terragruntConfig *TerragruntConfig) (string, error) { switch { case source != "": return source, nil case terragruntConfig.Terraform != nil && terragruntConfig.Terraform.Source != nil: return adjustSourceWithMap(sourceMap, *terragruntConfig.Terraform.Source, originalConfigPath) default: return ".", nil } } // adjustSourceWithMap implements the --terragrunt-source-map feature. This function will check if the URL portion of a // terraform source matches any entry in the provided source map and if it does, replace it with the configured source // in the map. Note that this only performs literal matches with the URL portion. // // Example: // Suppose terragrunt is called with: // // --terragrunt-source-map git::ssh://git@github.com/gruntwork-io/i-dont-exist.git=/path/to/local-modules // // and the terraform source is: // // git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixtures/source-map/modules/app?ref=master // // This function will take that source and transform it to: // // /path/to/local-modules/source-map/modules/app func adjustSourceWithMap(sourceMap map[string]string, source string, modulePath string) (string, error) { // Skip logic if source map is not configured if len(sourceMap) == 0 { return source, nil } // use go-getter to split the module source string into a valid URL and subdirectory (if // is present) moduleURL, moduleSubdir := getter.SourceDirSubdir(source) // if both URL and subdir are missing, something went terribly wrong if moduleURL == "" && moduleSubdir == "" { return "", errors.New(InvalidSourceURLWithMapError{ModulePath: modulePath, ModuleSourceURL: source}) } // If module URL is missing, return the source as is as it will not match anything in the map. if moduleURL == "" { return source, nil } // Before looking up in sourceMap, make sure to drop any query parameters. moduleURLParsed, err := url.Parse(moduleURL) if err != nil { return source, err } moduleURLParsed.RawQuery = "" moduleURLQuery := moduleURLParsed.String() // Check if there is an entry to replace the URL portion in the map. Return the source as is if there is no entry in // the map. sourcePath, hasKey := sourceMap[moduleURLQuery] if !hasKey { return source, nil } // Since there is a source mapping, replace the module URL portion with the entry in the map, and join with the // subdir. // If subdir is missing, check if we can obtain a valid module name from the URL portion. if moduleSubdir == "" { moduleSubdirFromURL, err := getModulePathFromSourceURL(moduleURL) if err != nil { return moduleSubdirFromURL, err } moduleSubdir = moduleSubdirFromURL } return util.JoinTerraformModulePath(sourcePath, moduleSubdir), nil } // GetDefaultConfigPath returns the default path to use for the Terragrunt configuration // that exists within the path giving preference to `terragrunt.hcl` func GetDefaultConfigPath(workingDir string) string { // check if a configuration file was passed as `workingDir`. if !files.IsDir(workingDir) && files.FileExists(workingDir) { return workingDir } var configPath string for _, configPath = range DefaultTerragruntConfigPaths { if !filepath.IsAbs(configPath) { configPath = filepath.Join(workingDir, configPath) } if files.FileExists(configPath) { break } } return configPath } // FindConfigFilesInPath returns a list of all Terragrunt config files in the given path or any subfolder of the path. // // Parameters: // - rootPath: the root directory to search // - experiments: experiment flags (for symlink support) // - configPath: the terragrunt config path (to detect non-default config filenames) // - env: environment variables (to resolve TF_DATA_DIR) // - downloadDir: the terragrunt download directory to skip func FindConfigFilesInPath( rootPath string, experiments experiment.Experiments, configPath string, env map[string]string, downloadDir string, ) ([]string, error) { configFiles := []string{} walkFunc := filepath.WalkDir if experiments.Evaluate(experiment.Symlinks) { walkFunc = util.WalkDirWithSymlinks } tfDataDir := tf.DefaultTFDataDir if d, ok := env["TF_DATA_DIR"]; ok { tfDataDir = d } err := walkFunc(rootPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } if !isTerragruntModuleDir(path, tfDataDir, downloadDir) { return filepath.SkipDir } for _, configFile := range append(DefaultTerragruntConfigPaths, filepath.Base(configPath)) { if !filepath.IsAbs(configFile) { configFile = filepath.Join(path, configFile) } if !util.IsDir(configFile) && util.FileExists(configFile) { configFiles = append(configFiles, configFile) break } } return nil }) return configFiles, err } // isTerragruntModuleDir returns true if the given path contains a Terragrunt module and false otherwise. The path // can not contain a cache, data, or download dir. func isTerragruntModuleDir(path string, tfDataDir string, downloadDir string) bool { // Skip the Terragrunt cache dir if util.ContainsPath(path, util.TerragruntCacheDir) { return false } // Skip the Terraform data dir if filepath.IsAbs(tfDataDir) { if util.HasPathPrefix(path, tfDataDir) { return false } } else { if util.ContainsPath(path, tfDataDir) { return false } } // Skip any custom download dir specified by the user if strings.Contains(filepath.Clean(path), filepath.Clean(downloadDir)) { return false } return true } // ReadTerragruntConfig reads the Terragrunt config file from its default location. // The caller provides a fully populated ParsingContext (typically via configbridge.NewParsingContext). func ReadTerragruntConfig(ctx context.Context, l log.Logger, pctx *ParsingContext, parserOptions []hclparse.Option, ) (*TerragruntConfig, error) { l.Debugf("Reading Terragrunt config file at %s", util.RelPathForLog(pctx.RootWorkingDir, pctx.TerragruntConfigPath, pctx.Writers.LogShowAbsPaths)) pctx = pctx.WithParseOption(parserOptions) return ParseConfigFile(ctx, pctx, l, pctx.TerragruntConfigPath, nil) } // ParseConfigFile parses the Terragrunt config file at the given path. If the include parameter is not nil, then treat this as a config // included in some other config file when resolving relative paths. func ParseConfigFile( ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string, includeFromChild *IncludeConfig, ) (*TerragruntConfig, error) { var err error pctx, err = pctx.WithIncrementedDepth() if err != nil { return nil, err } var config *TerragruntConfig hclCache := cache.ContextCache[*hclparse.File](ctx, HclCacheContextKey) // Build cache key components before tracing to determine cache hit status childKey := "nil" if includeFromChild != nil { childKey = includeFromChild.String() } decodeListKey := "nil" if pctx.PartialParseDecodeList != nil { decodeListKey = fmt.Sprintf("%v", pctx.PartialParseDecodeList) } fileInfo, err := os.Stat(configPath) if err != nil { if os.IsNotExist(err) { return nil, TerragruntConfigNotFoundError{Path: configPath} } return nil, errors.Errorf("failed to get file info: %w", err) } cacheKey := fmt.Sprintf("%v-%v-%v-%v-%v", configPath, pctx.WorkingDir, childKey, decodeListKey, fileInfo.ModTime().UnixMicro(), ) // Check cache hit status before tracing _, cacheHit := hclCache.Get(ctx, cacheKey) isPartial := len(pctx.PartialParseDecodeList) > 0 err = TraceParseConfigFile( ctx, configPath, pctx.WorkingDir, isPartial, pctx.PartialParseDecodeList, includeFromChild, cacheHit, func(childCtx context.Context) error { var file *hclparse.File if cacheConfig, found := hclCache.Get(childCtx, cacheKey); found { file = cacheConfig } else { // Parse the HCL file into an AST body that can be decoded multiple times later without having to re-parse var parseErr error file, parseErr = hclparse.NewParser(pctx.ParserOptions...).ParseFromFile(configPath) if parseErr != nil { return parseErr } hclCache.Put(childCtx, cacheKey, file) } var parseErr error config, parseErr = ParseConfig(childCtx, pctx, l, file, includeFromChild) if parseErr != nil { return parseErr } return nil }) if err != nil { return config, err } return config, nil } func ParseConfigString(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string, configString string, includeFromChild *IncludeConfig) (*TerragruntConfig, error) { // Parse the HCL file into an AST body that can be decoded multiple times later without having to re-parse file, err := hclparse.NewParser(pctx.ParserOptions...).ParseFromString(configString, configPath) if err != nil { return nil, err } config, err := ParseConfig(ctx, pctx, l, file, includeFromChild) if err != nil { return config, err } return config, nil } // ParseConfig parses the Terragrunt config contained in the given hcl file and merge it with the given include config (if any). Note // that the config parsing consists of multiple stages so as to allow referencing of data resulting from parsing // previous config. The parsing order is: // 1. Parse include. Include is parsed first and is used to import another config. All the config in the include block is // then merged into the current TerragruntConfig, except for locals (by design). Note that since the include block is // parsed first, you cannot reference locals in the include block config. // 2. Parse locals. Since locals are parsed next, you can only reference other locals in the locals block. Although it // is possible to merge locals from a config imported with an include block, we do not do that here to avoid // complicated referencing issues. Please refer to the globals proposal for an alternative that allows merging from // included config: https://github.com/gruntwork-io/terragrunt/issues/814 // Allowed References: // - locals // 3. Parse dependency blocks. This includes running `terragrunt output` to fetch the output data from another // terragrunt config, so that it is accessible within the config. See PartialParseConfigString for a way to parse the // blocks but avoid decoding. // Note that this step is skipped if we already retrieved all the dependencies (which is the case when parsing // included config files). This is determined by the dependencyOutputs input parameter. // Allowed References: // - locals // 4. Parse everything else. At this point, all the necessary building blocks for parsing the rest of the config are // available, so parse the rest of the config. // Allowed References: // - locals // - dependency // 5. Merge the included config with the parsed config. Note that all the config data is mergeable except for `locals` // blocks, which are only scoped to be available within the defining config. func ParseConfig( ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig, ) (*TerragruntConfig, error) { errs := &errors.MultiError{} if err := DetectDeprecatedConfigurations(ctx, pctx, l, file); err != nil { return nil, err } pctx = pctx.WithTrackInclude(nil) // Initial evaluation of configuration to load flags like IamRole which will be used for final parsing // https://github.com/gruntwork-io/terragrunt/issues/667 if err := setIAMRole(ctx, pctx, l, file, includeFromChild); err != nil { errs = errs.Append(err) } // read unit files and add to context unitValues, err := ReadValues(ctx, pctx, l, filepath.Dir(file.ConfigPath)) if err != nil { return nil, err } pctx = pctx.WithValues(unitValues) // Decode just the Base blocks. See the function docs for DecodeBaseBlocks for more info on what base blocks are. var baseBlocks *DecodedBaseBlocks baseBlocks, err = TraceParseBaseBlocks(ctx, l, file.ConfigPath, func(childCtx context.Context) (*DecodedBaseBlocks, error) { return DecodeBaseBlocks(childCtx, pctx, l, file, includeFromChild) }) if err != nil { errs = errs.Append(err) } if baseBlocks != nil { pctx = pctx.WithTrackInclude(baseBlocks.TrackInclude) pctx = pctx.WithFeatures(baseBlocks.FeatureFlags) pctx = pctx.WithLocals(baseBlocks.Locals) } // Emit additional trace with comprehensive base blocks details if baseBlocks != nil { TraceParseBaseBlocksResult(ctx, file.ConfigPath, baseBlocks) } if !pctx.SkipOutputsResolution && pctx.DecodedDependencies == nil { // Decode just the `dependency` blocks, retrieving the outputs from the target terragrunt config in the // process. retrievedOutputs, err := decodeAndRetrieveOutputs(ctx, pctx, l, file) if err != nil { errs = errs.Append(err) } pctx.DecodedDependencies = retrievedOutputs } evalContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { errs = errs.Append(err) } // Decode the rest of the config, passing in this config's `include` block or the child's `include` block, whichever // is appropriate var terragruntConfigFile *terragruntConfigFile err = TraceParseConfigDecode(ctx, file.ConfigPath, func(childCtx context.Context) error { var decodeErr error terragruntConfigFile, decodeErr = decodeAsTerragruntConfigFile(pctx, l, file, evalContext) return decodeErr }) if err != nil { errs = errs.Append(err) } if terragruntConfigFile == nil { return nil, errors.New(CouldNotResolveTerragruntConfigInFileError(file.ConfigPath)) } config, err := convertToTerragruntConfig(ctx, pctx, file.ConfigPath, terragruntConfigFile) if err != nil { errs = errs.Append(err) } // If this file includes another, parse and merge it. Otherwise, just return this config. // If there have been errors during this parse, don't attempt to parse the included config. if pctx.TrackInclude != nil { // Extract include paths for telemetry includeCount := len(pctx.TrackInclude.CurrentList) includePaths := make([]string, 0, includeCount) for _, inc := range pctx.TrackInclude.CurrentList { if inc.Path != "" { includePaths = append(includePaths, inc.Path) } } var mergedConfig *TerragruntConfig err = TraceParseIncludeMerge(ctx, file.ConfigPath, includeCount, includePaths, func(childCtx context.Context) error { var mergeErr error // Use the child context for trace propagation so include parsing is a child span mergedConfig, mergeErr = handleInclude(childCtx, pctx, l, config, false) return mergeErr }) if err != nil { errs = errs.Append(err) return config, errs.ErrorOrNil() } // We should never get a nil config here, so if we do, return the config we've been able to parse so far // and return any errors that have occurred so far to avoid a nil pointer dereference below. if mergedConfig == nil { return config, errs.ErrorOrNil() } // Saving processed includes into configuration, direct assignment since nested includes aren't supported mergedConfig.ProcessedIncludes = pctx.TrackInclude.CurrentMap // Make sure the top level information that is not automatically merged in is captured on the merged config to // ensure the proper representation of the config is captured. // - Locals are deliberately not merged in so that they remain local in scope. Here, we directly set it to the // original locals for the current config being handled, as that is the locals list that is in scope for this // config. mergedConfig.Locals = config.Locals mergedConfig.Exclude = config.Exclude return mergedConfig, errs.ErrorOrNil() } return config, errs.ErrorOrNil() } // DetectDeprecatedConfigurations detects if deprecated configurations are used in the given HCL file. func DetectDeprecatedConfigurations(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File) error { if DetectInputsCtyUsage(file) { // Dependency inputs (dependency.foo.inputs.bar) are now blocked by default for performance. // This deprecated feature causes significant performance overhead due to recursive parsing. return errors.New("Reading inputs from dependencies is no longer supported. To acquire values from dependencies, use outputs (dependency.foo.outputs.bar) instead.") } if detectBareIncludeUsage(file) { allControls := pctx.StrictControls bareInclude := allControls.Find(controls.BareInclude) if bareInclude == nil { return errors.New("failed to find control " + controls.BareInclude) } evalCtx := log.ContextWithLogger(ctx, l) if err := bareInclude.Evaluate(evalCtx); err != nil { return err } } return nil } // DetectInputsCtyUsage detects if an identifier matching dependency.foo.inputs.bar is used in the given HCL file. // // This is deprecated functionality, so we look for this to determine if we should throw an error or warning. func DetectInputsCtyUsage(file *hclparse.File) bool { body, ok := file.Body.(*hclsyntax.Body) if !ok { return false } for _, attr := range body.Attributes { for _, traversal := range attr.Expr.Variables() { const dependencyInputsIdentifierMinParts = 3 if len(traversal) < dependencyInputsIdentifierMinParts { continue } root, ok := traversal[0].(hcl.TraverseRoot) if !ok || root.Name != MetadataDependency { continue } attrTraversal, ok := traversal[2].(hcl.TraverseAttr) if !ok || attrTraversal.Name != MetadataInputs { continue } return true } } return false } // detectBareIncludeUsage detects if an identifier matching include.foo is used in the given HCL file. // // This is deprecated functionality, so we look for this to determine if we should throw an error or warning. func detectBareIncludeUsage(file *hclparse.File) bool { switch filepath.Ext(file.ConfigPath) { case ".json": var data map[string]any if err := json.Unmarshal(file.Bytes, &data); err != nil { // If JSON is invalid, it can't be a valid bare include structure. // The main parser will handle the invalid JSON error. return false } includeBlockUntyped, exists := data[MetadataInclude] if !exists { return false } switch includeBlockTyped := includeBlockUntyped.(type) { case map[string]any: // Delegate to the logic from include.go, which checks if the map // represents a bare include block (e.g., only known include attributes). return jsonIsIncludeBlock(includeBlockTyped) case []any: // A bare include in JSON array form must have exactly one element, // and that element must be an include block. if len(includeBlockTyped) == 1 { if firstElement, ok := includeBlockTyped[0].(map[string]any); ok { return jsonIsIncludeBlock(firstElement) } } return false default: return false } default: body, ok := file.Body.(*hclsyntax.Body) if !ok { return false } for _, block := range body.Blocks { if block.Type == MetadataInclude && len(block.Labels) == 0 { return true } } return false } } // iamRoleCache - store for cached values of IAM roles var iamRoleCache = cache.NewCache[iam.RoleOptions](iamRoleCacheName) // setIAMRole - extract IAM role details from Terragrunt flags block func setIAMRole(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig) error { // Prefer the IAM Role CLI args if they were passed otherwise lazily evaluate the IamRoleOptions using the config. if pctx.OriginalIAMRoleOptions.RoleARN != "" { pctx.IAMRoleOptions = pctx.OriginalIAMRoleOptions } else { // as key is considered HCL code and include configuration var ( key = fmt.Sprintf("%v-%v", file.Content(), includeFromChild) config, found = iamRoleCache.Get(ctx, key) ) if !found { iamConfig, err := TerragruntConfigFromPartialConfig(ctx, pctx.WithDecodeList(TerragruntFlags), l, file, includeFromChild) if err != nil { return err } config = iamConfig.GetIAMRoleOptions() iamRoleCache.Put(ctx, key, config) } // We merge the OriginalIAMRoleOptions into the one from the config, because the CLI passed IAMRoleOptions has // precedence. merged := iam.MergeRoleOptions( config, pctx.OriginalIAMRoleOptions, ) pctx.IAMRoleOptions = merged } return nil } func decodeAsTerragruntConfigFile(pctx *ParsingContext, l log.Logger, file *hclparse.File, evalContext *hcl.EvalContext) (*terragruntConfigFile, error) { terragruntConfig := terragruntConfigFile{} if err := file.Decode(&terragruntConfig, evalContext); err != nil { var diagErr hcl.Diagnostics ok := errors.As(err, &diagErr) // in case of render-json command and inputs reference error, we update the inputs with default value if (!ok || !isRenderJSONCommand(pctx) || !isAttributeAccessError(diagErr)) && (!ok || !isRenderCommand(pctx) || !isAttributeAccessError(diagErr)) { return &terragruntConfig, err } l.Warnf("Failed to decode inputs %v", diagErr) } if terragruntConfig.Inputs != nil { inputs, err := ctyhelper.UpdateUnknownCtyValValues(*terragruntConfig.Inputs) if err != nil { return nil, err } terragruntConfig.Inputs = &inputs } return &terragruntConfig, nil } // Returns the index of the Hook with the given name, // or -1 if no Hook have the given name. func getIndexOfHookWithName(hooks []Hook, name string) int { for i, hook := range hooks { if hook.Name == name { return i } } return -1 } // isAttributeAccessError returns true if the given diagnostics indicate an error accessing an attribute func isAttributeAccessError(diagnostics hcl.Diagnostics) bool { for _, diagnostic := range diagnostics { if diagnostic.Severity == hcl.DiagError && strings.Contains(diagnostic.Summary, "Unsupported attribute") { return true } } return false } // Returns the index of the ErrorHook with the given name, // or -1 if no Hook have the given name. // TODO: Figure out more DRY way to do this func getIndexOfErrorHookWithName(hooks []ErrorHook, name string) int { for i, hook := range hooks { if hook.Name == name { return i } } return -1 } // Returns the index of the extraArgs with the given name, // or -1 if no extraArgs have the given name. func getIndexOfExtraArgsWithName(extraArgs []TerraformExtraArguments, name string) int { for i, extra := range extraArgs { if extra.Name == name { return i } } return -1 } // Convert the contents of a fully resolved Terragrunt configuration to a TerragruntConfig object func convertToTerragruntConfig(ctx context.Context, pctx *ParsingContext, configPath string, terragruntConfigFromFile *terragruntConfigFile) (cfg *TerragruntConfig, err error) { errs := &errors.MultiError{} if pctx.ConvertToTerragruntConfigFunc != nil { return pctx.ConvertToTerragruntConfigFunc(ctx, pctx, configPath, terragruntConfigFromFile) } terragruntConfig := &TerragruntConfig{ IsPartial: false, // Initialize GenerateConfigs so we can append to it GenerateConfigs: map[string]codegen.GenerateConfig{}, } defaultMetadata := map[string]any{FoundInFile: configPath} if terragruntConfigFromFile.RemoteState != nil { config, err := terragruntConfigFromFile.RemoteState.Config() if err != nil { errs = errs.Append(err) } terragruntConfig.RemoteState = remotestate.New(config) terragruntConfig.SetFieldMetadata(MetadataRemoteState, defaultMetadata) } if terragruntConfigFromFile.RemoteStateAttr != nil { remoteStateMap, err := ctyhelper.ParseCtyValueToMap(*terragruntConfigFromFile.RemoteStateAttr) if err != nil { return nil, err } var config *remotestate.Config if err := mapstructure.WeakDecode(remoteStateMap, &config); err != nil { return nil, err } terragruntConfig.RemoteState = remotestate.New(config) terragruntConfig.SetFieldMetadata(MetadataRemoteState, defaultMetadata) } if err := terragruntConfigFromFile.Terraform.ValidateHooks(); err != nil { errs = errs.Append(err) } terragruntConfig.Terraform = terragruntConfigFromFile.Terraform if terragruntConfig.Terraform != nil { // since Terraform is nil each time avoid saving metadata when it is nil terragruntConfig.SetFieldMetadata(MetadataTerraform, defaultMetadata) } if err := validateDependencies(pctx, terragruntConfigFromFile.Dependencies); err != nil { errs = errs.Append(err) } terragruntConfig.Dependencies = terragruntConfigFromFile.Dependencies if terragruntConfig.Dependencies != nil { for _, item := range terragruntConfig.Dependencies.Paths { terragruntConfig.SetFieldMetadataWithType(MetadataDependencies, item, defaultMetadata) } } terragruntConfig.TerragruntDependencies = terragruntConfigFromFile.TerragruntDependencies for _, dep := range terragruntConfig.TerragruntDependencies { terragruntConfig.SetFieldMetadataWithType(MetadataDependency, dep.Name, defaultMetadata) } if terragruntConfigFromFile.TerraformBinary != nil { terragruntConfig.TerraformBinary = *terragruntConfigFromFile.TerraformBinary terragruntConfig.SetFieldMetadata(MetadataTerraformBinary, defaultMetadata) } if terragruntConfigFromFile.DownloadDir != nil { terragruntConfig.DownloadDir = *terragruntConfigFromFile.DownloadDir terragruntConfig.SetFieldMetadata(MetadataDownloadDir, defaultMetadata) } if terragruntConfigFromFile.TerraformVersionConstraint != nil { terragruntConfig.TerraformVersionConstraint = *terragruntConfigFromFile.TerraformVersionConstraint terragruntConfig.SetFieldMetadata(MetadataTerraformVersionConstraint, defaultMetadata) } if terragruntConfigFromFile.TerragruntVersionConstraint != nil { terragruntConfig.TerragruntVersionConstraint = *terragruntConfigFromFile.TerragruntVersionConstraint terragruntConfig.SetFieldMetadata(MetadataTerragruntVersionConstraint, defaultMetadata) } if terragruntConfigFromFile.PreventDestroy != nil { terragruntConfig.PreventDestroy = terragruntConfigFromFile.PreventDestroy terragruntConfig.SetFieldMetadata(MetadataPreventDestroy, defaultMetadata) } if terragruntConfigFromFile.IamRole != nil { terragruntConfig.IamRole = *terragruntConfigFromFile.IamRole terragruntConfig.SetFieldMetadata(MetadataIamRole, defaultMetadata) } if terragruntConfigFromFile.IamAssumeRoleDuration != nil { terragruntConfig.IamAssumeRoleDuration = terragruntConfigFromFile.IamAssumeRoleDuration terragruntConfig.SetFieldMetadata(MetadataIamAssumeRoleDuration, defaultMetadata) } if terragruntConfigFromFile.IamAssumeRoleSessionName != nil { terragruntConfig.IamAssumeRoleSessionName = *terragruntConfigFromFile.IamAssumeRoleSessionName terragruntConfig.SetFieldMetadata(MetadataIamAssumeRoleSessionName, defaultMetadata) } if terragruntConfigFromFile.IamWebIdentityToken != nil { terragruntConfig.IamWebIdentityToken = *terragruntConfigFromFile.IamWebIdentityToken terragruntConfig.SetFieldMetadata(MetadataIamWebIdentityToken, defaultMetadata) } if terragruntConfigFromFile.Engine != nil { terragruntConfig.Engine = terragruntConfigFromFile.Engine terragruntConfig.SetFieldMetadata(MetadataEngine, defaultMetadata) } if terragruntConfigFromFile.FeatureFlags != nil { terragruntConfig.FeatureFlags = terragruntConfigFromFile.FeatureFlags for _, flag := range terragruntConfig.FeatureFlags { terragruntConfig.SetFieldMetadataWithType(MetadataFeatureFlag, flag.Name, defaultMetadata) } } if terragruntConfigFromFile.Exclude != nil { terragruntConfig.Exclude = terragruntConfigFromFile.Exclude terragruntConfig.SetFieldMetadata(MetadataExclude, defaultMetadata) } if terragruntConfigFromFile.Errors != nil { terragruntConfig.Errors = terragruntConfigFromFile.Errors terragruntConfig.SetFieldMetadata(MetadataErrors, defaultMetadata) } generateBlocks := []terragruntGenerateBlock{} generateBlocks = append(generateBlocks, terragruntConfigFromFile.GenerateBlocks...) if terragruntConfigFromFile.GenerateAttrs != nil { generateMap, err := ctyhelper.ParseCtyValueToMap(*terragruntConfigFromFile.GenerateAttrs) if err != nil { return nil, err } for name, block := range generateMap { var generateBlock terragruntGenerateBlock if err := mapstructure.WeakDecode(block, &generateBlock); err != nil { return nil, err } generateBlock.Name = name generateBlocks = append(generateBlocks, generateBlock) } } if err := validateGenerateBlocks(&generateBlocks); err != nil { errs = errs.Append(err) } for _, block := range generateBlocks { // Validate that if_exists is provided (required attribute) if block.IfExists == "" { errs = errs.Append(errors.Errorf("generate block %q is missing required attribute \"if_exists\"", block.Name)) continue } ifExists, err := codegen.GenerateConfigExistsFromString(block.IfExists) if err != nil { errs = errs.Append(errors.Errorf("generate block %q: %w", block.Name, err)) continue } if block.IfDisabled == nil { block.IfDisabled = &DefaultGenerateBlockIfDisabledValueStr } ifDisabled, err := codegen.GenerateConfigDisabledFromString(*block.IfDisabled) if err != nil { return nil, err } genConfig := codegen.GenerateConfig{ Path: block.Path, IfExists: ifExists, IfExistsStr: block.IfExists, IfDisabled: ifDisabled, IfDisabledStr: *block.IfDisabled, Contents: block.Contents, } if block.CommentPrefix == nil { genConfig.CommentPrefix = codegen.DefaultCommentPrefix } else { genConfig.CommentPrefix = *block.CommentPrefix } if block.DisableSignature == nil { genConfig.DisableSignature = false } else { genConfig.DisableSignature = *block.DisableSignature } if block.Disable == nil { genConfig.Disable = false } else { genConfig.Disable = *block.Disable } terragruntConfig.GenerateConfigs[block.Name] = genConfig terragruntConfig.SetFieldMetadataWithType(MetadataGenerateConfigs, block.Name, defaultMetadata) } if terragruntConfigFromFile.Inputs != nil { inputs, err := ctyhelper.ParseCtyValueToMap(*terragruntConfigFromFile.Inputs) if err != nil { errs = errs.Append(err) } terragruntConfig.Inputs = inputs terragruntConfig.SetFieldMetadataMap(MetadataInputs, terragruntConfig.Inputs, defaultMetadata) } if pctx.Locals != nil && *pctx.Locals != cty.NilVal { localsParsed, err := ctyhelper.ParseCtyValueToMap(*pctx.Locals) if err != nil { return nil, err } // Only set Locals if there are actual values to avoid setting an empty map if len(localsParsed) > 0 { terragruntConfig.Locals = localsParsed terragruntConfig.SetFieldMetadataMap(MetadataLocals, localsParsed, defaultMetadata) } } return terragruntConfig, errs.ErrorOrNil() } // Iterate over dependencies paths and check if directories exists, return error with all missing dependencies func validateDependencies(ctx *ParsingContext, dependencies *ModuleDependencies) error { var missingDependencies []string if dependencies == nil { return nil } for _, dependencyPath := range dependencies.Paths { fullPath := filepath.FromSlash(dependencyPath) if !filepath.IsAbs(fullPath) { fullPath = path.Join(ctx.WorkingDir, fullPath) } if !util.IsDir(fullPath) { missingDependencies = append(missingDependencies, fmt.Sprintf("%s (%s)", dependencyPath, fullPath)) } } if len(missingDependencies) > 0 { return DependencyDirNotFoundError{missingDependencies} } return nil } // Iterate over generate blocks and detect duplicate names, return error with list of duplicated names func validateGenerateBlocks(blocks *[]terragruntGenerateBlock) error { var ( blockNames = map[string]bool{} duplicatedGenerateBlockNames []string ) for _, block := range *blocks { _, found := blockNames[block.Name] if found { duplicatedGenerateBlockNames = append(duplicatedGenerateBlockNames, block.Name) continue } blockNames[block.Name] = true } if len(duplicatedGenerateBlockNames) != 0 { return DuplicatedGenerateBlocksError{duplicatedGenerateBlockNames} } return nil } // configFileHasDependencyBlock statically checks the terrragrunt config file at the given path and checks if it has any // dependency or dependencies blocks defined. Note that this does not do any decoding of the blocks, as it is only meant // to check for block presence. func configFileHasDependencyBlock(configPath string) (bool, error) { configBytes, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { return false, DependencyFileNotFoundError{Path: configPath} } return false, errors.New(err) } // We use hclwrite to parse the config instead of the normal parser because the normal parser doesn't give us an AST // that we can walk and scan, and requires structured data to map against. This makes the parsing strict, so to // avoid weird parsing errors due to missing dependency data, we do a structural scan here. hclFile, diags := hclwrite.ParseConfig(configBytes, configPath, hcl.InitialPos) if diags.HasErrors() { return false, errors.New(diags) } for _, block := range hclFile.Body().Blocks() { if block.Type() == "dependency" || block.Type() == "dependencies" { return true, nil } } return false, nil } // SetFieldMetadataWithType set metadata on the given field name grouped by type. // Example usage - setting metadata on different dependencies, locals, inputs. func (cfg *TerragruntConfig) SetFieldMetadataWithType(fieldType, fieldName string, m map[string]any) { if cfg.FieldsMetadata == nil { cfg.FieldsMetadata = map[string]map[string]any{} } field := fmt.Sprintf("%s-%s", fieldType, fieldName) metadata, found := cfg.FieldsMetadata[field] if !found { metadata = make(map[string]any) } maps.Copy(metadata, m) cfg.FieldsMetadata[field] = metadata } // SetFieldMetadata set metadata on the given field name. func (cfg *TerragruntConfig) SetFieldMetadata(fieldName string, m map[string]any) { cfg.SetFieldMetadataWithType(fieldName, fieldName, m) } // SetFieldMetadataMap set metadata on fields from map keys. // Example usage - setting metadata on all variables from inputs. func (cfg *TerragruntConfig) SetFieldMetadataMap(field string, data map[string]any, metadata map[string]any) { for name := range data { cfg.SetFieldMetadataWithType(field, name, metadata) } } // GetFieldMetadata return field metadata by field name. func (cfg *TerragruntConfig) GetFieldMetadata(fieldName string) (map[string]string, bool) { return cfg.GetMapFieldMetadata(fieldName, fieldName) } // GetMapFieldMetadata return field metadata by field type and name. func (cfg *TerragruntConfig) GetMapFieldMetadata(fieldType, fieldName string) (map[string]string, bool) { if cfg.FieldsMetadata == nil { return nil, false } field := fmt.Sprintf("%s-%s", fieldType, fieldName) value, found := cfg.FieldsMetadata[field] if !found { return nil, false } result := make(map[string]string) for key, value := range value { result[key] = fmt.Sprintf("%v", value) } return result, found } // EngineOptions fetch engine options func (cfg *TerragruntConfig) EngineOptions() (*engine.EngineConfig, error) { if cfg.Engine == nil { return nil, nil } // in case of Meta is null, set empty meta meta := map[string]any{} if cfg.Engine.Meta != nil { parsedMeta, err := ctyhelper.ParseCtyValueToMap(*cfg.Engine.Meta) if err != nil { return nil, err } meta = parsedMeta } var version, engineType string if cfg.Engine.Version != nil { version = *cfg.Engine.Version } if cfg.Engine.Type != nil { engineType = *cfg.Engine.Type } // if type is null of empty, set to "rpc" if len(engineType) == 0 { engineType = DefaultEngineType } return &engine.EngineConfig{ Source: cfg.Engine.Source, Version: version, Type: engineType, Meta: meta, }, nil } // ErrorsConfig fetch errors configuration for options package func (cfg *TerragruntConfig) ErrorsConfig() (*errorconfig.Config, error) { if cfg.Errors == nil { return nil, nil } result := &errorconfig.Config{ Retry: make(map[string]*errorconfig.RetryConfig), Ignore: make(map[string]*errorconfig.IgnoreConfig), } for _, retryBlock := range cfg.Errors.Retry { if retryBlock == nil { continue } // Validate retry settings if retryBlock.MaxAttempts < 1 { return nil, errors.Errorf("cannot have less than 1 max retry in errors.retry %q, but you specified %d", retryBlock.Label, retryBlock.MaxAttempts) } if retryBlock.SleepIntervalSec < 0 { return nil, errors.Errorf("cannot sleep for less than 0 seconds in errors.retry %q, but you specified %d", retryBlock.Label, retryBlock.SleepIntervalSec) } compiledPatterns := make([]*errorconfig.Pattern, 0, len(retryBlock.RetryableErrors)) for _, pattern := range retryBlock.RetryableErrors { value, err := errorsPattern(pattern) if err != nil { return nil, errors.Errorf("invalid retry pattern %q in block %q: %w", pattern, retryBlock.Label, err) } compiledPatterns = append(compiledPatterns, value) } result.Retry[retryBlock.Label] = &errorconfig.RetryConfig{ Name: retryBlock.Label, RetryableErrors: compiledPatterns, MaxAttempts: retryBlock.MaxAttempts, SleepIntervalSec: retryBlock.SleepIntervalSec, } } for _, ignoreBlock := range cfg.Errors.Ignore { if ignoreBlock == nil { continue } var signals map[string]any if ignoreBlock.Signals != nil { value, err := ConvertValuesMapToCtyVal(ignoreBlock.Signals) if err != nil { return nil, err } signals, err = ctyhelper.ParseCtyValueToMap(value) if err != nil { return nil, err } } compiledPatterns := make([]*errorconfig.Pattern, 0, len(ignoreBlock.IgnorableErrors)) for _, pattern := range ignoreBlock.IgnorableErrors { value, err := errorsPattern(pattern) if err != nil { return nil, errors.Errorf("invalid ignore pattern %q in block %q: %w", pattern, ignoreBlock.Label, err) } compiledPatterns = append(compiledPatterns, value) } result.Ignore[ignoreBlock.Label] = &errorconfig.IgnoreConfig{ Name: ignoreBlock.Label, IgnorableErrors: compiledPatterns, Message: ignoreBlock.Message, Signals: signals, } } return result, nil } // Build ErrorsPattern from string func errorsPattern(pattern string) (*errorconfig.Pattern, error) { isNegative := false p := pattern if len(p) > 0 && p[0] == '!' { isNegative = true p = p[1:] } compiled, err := regexp.Compile(p) if err != nil { return nil, err } return &errorconfig.Pattern{ Pattern: compiled, Negative: isNegative, }, nil } // ParseRemoteState reads the Terragrunt config file from its default location // and parses and returns the `remote_state` block. // The caller provides a fully populated ParsingContext (typically via configbridge.NewParsingContext). func ParseRemoteState(ctx context.Context, l log.Logger, pctx *ParsingContext) (*remotestate.RemoteState, error) { cfg, err := ReadTerragruntConfig(ctx, l, pctx, pctx.ParserOptions) if err != nil { return nil, err } return cfg.GetRemoteState(l, pctx) } ================================================ FILE: pkg/config/config_as_cty.go ================================================ package config import ( "encoding/json" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/remotestate" ) // TerragruntConfigAsCty serializes TerragruntConfig struct to a cty Value that can be used to reference the attributes in other config. Note // that we can't straight up convert the struct using cty tags due to differences in the desired representation. // Specifically, we want to reference blocks by named attributes, but blocks are rendered to lists in the // TerragruntConfig struct, so we need to do some massaging of the data to convert the list of blocks in to a map going // from the block name label to the block value. func TerragruntConfigAsCty(config *TerragruntConfig) (cty.Value, error) { output := map[string]cty.Value{} // Convert attributes that are primitive types output[MetadataTerraformBinary] = gostringToCty(config.TerraformBinary) output[MetadataTerraformVersionConstraint] = gostringToCty(config.TerraformVersionConstraint) output[MetadataTerragruntVersionConstraint] = gostringToCty(config.TerragruntVersionConstraint) output[MetadataDownloadDir] = gostringToCty(config.DownloadDir) output[MetadataIamRole] = gostringToCty(config.IamRole) output[MetadataIamAssumeRoleSessionName] = gostringToCty(config.IamAssumeRoleSessionName) output[MetadataIamWebIdentityToken] = gostringToCty(config.IamWebIdentityToken) catalogConfigCty, err := catalogConfigAsCty(config.Catalog) if err != nil { return cty.NilVal, err } if catalogConfigCty != cty.NilVal { output[MetadataCatalog] = catalogConfigCty } engineConfigCty, err := engineConfigAsCty(config.Engine) if err != nil { return cty.NilVal, err } if engineConfigCty != cty.NilVal { output[MetadataEngine] = engineConfigCty } excludeConfigCty, err := excludeConfigAsCty(config.Exclude) if err != nil { return cty.NilVal, err } if excludeConfigCty != cty.NilVal { output[MetadataExclude] = excludeConfigCty } errorsConfigCty, err := errorsConfigAsCty(config.Errors) if err != nil { return cty.NilVal, err } if errorsConfigCty != cty.NilVal { output[MetadataErrors] = errorsConfigCty } terraformConfigCty, err := terraformConfigAsCty(config.Terraform) if err != nil { return cty.NilVal, err } if terraformConfigCty != cty.NilVal { output[MetadataTerraform] = terraformConfigCty } remoteStateCty, err := RemoteStateAsCty(config.RemoteState) if err != nil { return cty.NilVal, err } if remoteStateCty != cty.NilVal { output[MetadataRemoteState] = remoteStateCty } dependenciesCty, err := GoTypeToCty(config.Dependencies) if err != nil { return cty.NilVal, err } if dependenciesCty != cty.NilVal { output[MetadataDependencies] = dependenciesCty } if config.PreventDestroy != nil { output[MetadataPreventDestroy] = goboolToCty(*config.PreventDestroy) } dependencyCty, err := dependencyBlocksAsCty(config.TerragruntDependencies) if err != nil { return cty.NilVal, err } if dependencyCty != cty.NilVal { output[MetadataDependency] = dependencyCty } generateCty, err := GoTypeToCty(config.GenerateConfigs) if err != nil { return cty.NilVal, err } if generateCty != cty.NilVal { output[MetadataGenerateConfigs] = generateCty } iamAssumeRoleDurationCty, err := GoTypeToCty(config.IamAssumeRoleDuration) if err != nil { return cty.NilVal, err } if iamAssumeRoleDurationCty != cty.NilVal { output[MetadataIamAssumeRoleDuration] = iamAssumeRoleDurationCty } inputsCty, err := convertToCtyWithJSON(config.Inputs) if err != nil { return cty.NilVal, err } if inputsCty != cty.NilVal { output[MetadataInputs] = inputsCty } localsCty, err := convertToCtyWithJSON(config.Locals) if err != nil { return cty.NilVal, err } if localsCty != cty.NilVal { output[MetadataLocals] = localsCty } featureFlagsCty, err := featureFlagsBlocksAsCty(config.FeatureFlags) if err != nil { return cty.NilVal, err } if featureFlagsCty != cty.NilVal { output[MetadataFeatureFlag] = featureFlagsCty } return ConvertValuesMapToCtyVal(output) } func TerragruntConfigAsCtyWithMetadata(config *TerragruntConfig) (cty.Value, error) { output := map[string]cty.Value{} // Convert attributes that are primitive types if err := wrapWithMetadata(config, config.TerraformBinary, MetadataTerraformBinary, &output); err != nil { return cty.NilVal, err } if err := wrapWithMetadata(config, config.TerraformVersionConstraint, MetadataTerraformVersionConstraint, &output); err != nil { return cty.NilVal, err } if err := wrapWithMetadata(config, config.TerragruntVersionConstraint, MetadataTerragruntVersionConstraint, &output); err != nil { return cty.NilVal, err } if err := wrapWithMetadata(config, config.DownloadDir, MetadataDownloadDir, &output); err != nil { return cty.NilVal, err } if err := wrapWithMetadata(config, config.IamRole, MetadataIamRole, &output); err != nil { return cty.NilVal, err } if err := wrapWithMetadata(config, config.IamAssumeRoleSessionName, MetadataIamAssumeRoleSessionName, &output); err != nil { return cty.NilVal, err } if config.PreventDestroy != nil { if err := wrapWithMetadata(config, *config.PreventDestroy, MetadataPreventDestroy, &output); err != nil { return cty.NilVal, err } } if err := wrapWithMetadata(config, config.IamAssumeRoleDuration, MetadataIamAssumeRoleDuration, &output); err != nil { return cty.NilVal, err } terraformConfigCty, err := terraformConfigAsCty(config.Terraform) if err != nil { return cty.NilVal, err } if terraformConfigCty != cty.NilVal { if err := wrapWithMetadata(config, terraformConfigCty, MetadataTerraform, &output); err != nil { return cty.NilVal, err } } // Remote state remoteStateCty, err := RemoteStateAsCty(config.RemoteState) if err != nil { return cty.NilVal, err } if remoteStateCty != cty.NilVal { if err := wrapWithMetadata(config, remoteStateCty, MetadataRemoteState, &output); err != nil { return cty.NilVal, err } } if err := wrapCtyMapWithMetadata(config, &config.Inputs, MetadataInputs, &output); err != nil { return cty.NilVal, err } if err := wrapCtyMapWithMetadata(config, &config.Locals, MetadataLocals, &output); err != nil { return cty.NilVal, err } // remder dependencies as list of maps with "value" and "metadata" if config.Dependencies != nil { var dependencyWithMetadata = make([]ValueWithMetadata, 0, len(config.Dependencies.Paths)) for _, dependency := range config.Dependencies.Paths { var content = ValueWithMetadata{} content.Value = gostringToCty(dependency) metadata, found := config.GetMapFieldMetadata(MetadataDependencies, dependency) if found { content.Metadata = metadata } dependencyWithMetadata = append(dependencyWithMetadata, content) } dependenciesCty, err := GoTypeToCty(dependencyWithMetadata) if err != nil { return cty.NilVal, err } output[MetadataDependencies] = dependenciesCty } if config.TerragruntDependencies != nil { var dependenciesMap = map[string]cty.Value{} for _, block := range config.TerragruntDependencies { ctyValue, err := GoTypeToCty(block) if err != nil { continue } if ctyValue == cty.NilVal { continue } var content = ValueWithMetadata{} content.Value = ctyValue metadata, found := config.GetMapFieldMetadata(MetadataDependency, block.Name) if found { content.Metadata = metadata } value, err := GoTypeToCty(content) if err != nil { continue } dependenciesMap[block.Name] = value } if len(dependenciesMap) > 0 { dependenciesCty, err := ConvertValuesMapToCtyVal(dependenciesMap) if err != nil { return cty.NilVal, err } output[MetadataDependency] = dependenciesCty } } if config.GenerateConfigs != nil { var generateConfigsWithMetadata = map[string]cty.Value{} for key, value := range config.GenerateConfigs { ctyValue, err := GoTypeToCty(value) if err != nil { continue } if ctyValue == cty.NilVal { continue } var content = ValueWithMetadata{} content.Value = ctyValue metadata, found := config.GetMapFieldMetadata(MetadataGenerateConfigs, key) if found { content.Metadata = metadata } v, err := GoTypeToCty(content) if err != nil { continue } generateConfigsWithMetadata[key] = v } if len(generateConfigsWithMetadata) > 0 { dependenciesCty, err := ConvertValuesMapToCtyVal(generateConfigsWithMetadata) if err != nil { return cty.NilVal, err } output[MetadataGenerateConfigs] = dependenciesCty } } return ConvertValuesMapToCtyVal(output) } func wrapCtyMapWithMetadata(config *TerragruntConfig, data *map[string]any, fieldType string, output *map[string]cty.Value) error { var valueWithMetadata = map[string]cty.Value{} for key, value := range *data { var content = ValueWithMetadata{} ctyValue, err := convertToCtyWithJSON(value) if err != nil { return err } content.Value = ctyValue metadata, found := config.GetMapFieldMetadata(fieldType, key) if found { content.Metadata = metadata } v, err := GoTypeToCty(content) if err != nil { continue } valueWithMetadata[key] = v } if len(valueWithMetadata) > 0 { localsCty, err := ConvertValuesMapToCtyVal(valueWithMetadata) if err != nil { return err } (*output)[fieldType] = localsCty } return nil } func wrapWithMetadata(config *TerragruntConfig, value any, metadataName string, output *map[string]cty.Value) error { if value == nil { return nil } var valueWithMetadata = ValueWithMetadata{} ctyValue, err := GoTypeToCty(value) if err != nil { return err } valueWithMetadata.Value = ctyValue metadata, found := config.GetFieldMetadata(metadataName) if found { valueWithMetadata.Metadata = metadata } ctyJSON, err := GoTypeToCty(valueWithMetadata) if err != nil { return err } if ctyJSON != cty.NilVal { (*output)[metadataName] = ctyJSON } return nil } // ValueWithMetadata stores value and metadata used in render-json with metadata. type ValueWithMetadata struct { Value cty.Value `json:"value" cty:"value"` Metadata map[string]string `json:"metadata" cty:"metadata"` } // ctyCatalogConfig is an alternate representation of CatalogConfig that converts internal blocks into a map that // maps the name to the underlying struct, as opposed to a list representation. type ctyCatalogConfig struct { URLs []string `cty:"urls"` } // ctyEngineConfig is an alternate representation of EngineConfig that converts internal blocks into a map that // maps the name to the underlying struct, as opposed to a list representation. type ctyEngineConfig struct { Meta cty.Value `cty:"meta"` Source string `cty:"source"` Version string `cty:"version"` Type string `cty:"type"` } // ctyExclude exclude representation for cty. type ctyExclude struct { Actions []string `cty:"actions"` If bool `cty:"if"` ExcludeDependencies bool `cty:"exclude_dependencies"` } // Serialize CatalogConfig to a cty Value, but with maps instead of lists for the blocks. func catalogConfigAsCty(config *CatalogConfig) (cty.Value, error) { if config == nil { return cty.NilVal, nil } configCty := ctyCatalogConfig{ URLs: config.URLs, } return GoTypeToCty(configCty) } // Serialize engineConfigAsCty to a cty Value, but with maps instead of lists for the blocks. func engineConfigAsCty(config *EngineConfig) (cty.Value, error) { if config == nil { return cty.NilVal, nil } var v, t string if config.Version != nil { v = *config.Version } if config.Type != nil { t = *config.Type } configCty := ctyEngineConfig{ Source: config.Source, Version: v, Type: t, } if config.Meta != nil { configCty.Meta = *config.Meta } return GoTypeToCty(configCty) } // excludeConfigAsCty serialize exclude configuration to a cty Value. func excludeConfigAsCty(config *ExcludeConfig) (cty.Value, error) { if config == nil { return cty.NilVal, nil } excludeDependencies := false if config.ExcludeDependencies != nil { excludeDependencies = *config.ExcludeDependencies } configCty := ctyExclude{ If: config.If, Actions: config.Actions, ExcludeDependencies: excludeDependencies, } return GoTypeToCty(configCty) } // CtyTerraformConfig is an alternate representation of TerraformConfig that converts internal blocks into a map that // maps the name to the underlying struct, as opposed to a list representation. type CtyTerraformConfig struct { ExtraArgs map[string]TerraformExtraArguments `cty:"extra_arguments"` Source *string `cty:"source"` IncludeInCopy *[]string `cty:"include_in_copy"` ExcludeFromCopy *[]string `cty:"exclude_from_copy"` CopyTerraformLockFile *bool `cty:"copy_terraform_lock_file"` BeforeHooks map[string]Hook `cty:"before_hook"` AfterHooks map[string]Hook `cty:"after_hook"` ErrorHooks map[string]ErrorHook `cty:"error_hook"` } // Serialize TerraformConfig to a cty Value, but with maps instead of lists for the blocks. func terraformConfigAsCty(config *TerraformConfig) (cty.Value, error) { if config == nil { return cty.NilVal, nil } configCty := CtyTerraformConfig{ Source: config.Source, IncludeInCopy: config.IncludeInCopy, ExcludeFromCopy: config.ExcludeFromCopy, CopyTerraformLockFile: config.CopyTerraformLockFile, ExtraArgs: map[string]TerraformExtraArguments{}, BeforeHooks: map[string]Hook{}, AfterHooks: map[string]Hook{}, ErrorHooks: map[string]ErrorHook{}, } for _, arg := range config.ExtraArgs { configCty.ExtraArgs[arg.Name] = arg } for _, hook := range config.BeforeHooks { configCty.BeforeHooks[hook.Name] = hook } for _, hook := range config.AfterHooks { configCty.AfterHooks[hook.Name] = hook } for _, errorHook := range config.ErrorHooks { configCty.ErrorHooks[errorHook.Name] = errorHook } return GoTypeToCty(configCty) } // RemoteStateAsCty serializes RemoteState to a cty Value. We can't directly // serialize the struct because `config` and `encryption` are arbitrary // interfaces whose type we do not know, so we have to do a hack to go through json. func RemoteStateAsCty(remote *remotestate.RemoteState) (cty.Value, error) { if remote == nil || remote.Config == nil { return cty.NilVal, nil } config := remote.Config output := map[string]cty.Value{} output["backend"] = gostringToCty(config.BackendName) output["disable_init"] = goboolToCty(config.DisableInit) output["disable_dependency_optimization"] = goboolToCty(config.DisableDependencyOptimization) generateCty, err := GoTypeToCty(config.Generate) if err != nil { return cty.NilVal, err } output["generate"] = generateCty ctyJSONVal, err := convertToCtyWithJSON(config.BackendConfig) if err != nil { return cty.NilVal, err } output["config"] = ctyJSONVal ctyJSONVal, err = convertToCtyWithJSON(config.Encryption) if err != nil { return cty.NilVal, err } output["encryption"] = ctyJSONVal return ConvertValuesMapToCtyVal(output) } // Serialize the list of dependency blocks to a cty Value as a map that maps the block names to the cty representation. func dependencyBlocksAsCty(dependencyBlocks Dependencies) (cty.Value, error) { out := map[string]cty.Value{} for _, block := range dependencyBlocks { blockCty, err := GoTypeToCty(block) if err != nil { return cty.NilVal, err } out[block.Name] = blockCty } return ConvertValuesMapToCtyVal(out) } // Serialize the list of feature flags to a cty Value as a map that maps the feature names to the cty representation. func featureFlagsBlocksAsCty(featureFlagBlocks FeatureFlags) (cty.Value, error) { out := map[string]cty.Value{} for _, feature := range featureFlagBlocks { featureCty, err := GoTypeToCty(feature) if err != nil { return cty.NilVal, err } out[feature.Name] = featureCty } return ConvertValuesMapToCtyVal(out) } // Serialize errors configuration as cty.Value. func errorsConfigAsCty(config *ErrorsConfig) (cty.Value, error) { if config == nil { return cty.NilVal, nil } output := map[string]cty.Value{} retryCty, err := GoTypeToCty(config.Retry) if err != nil { return cty.NilVal, err } if retryCty != cty.NilVal { output[MetadataRetry] = retryCty } ignoreCty, err := GoTypeToCty(config.Ignore) if err != nil { return cty.NilVal, err } if ignoreCty != cty.NilVal { output[MetadataIgnore] = ignoreCty } return ConvertValuesMapToCtyVal(output) } // stackConfigAsCty converts a StackConfig into a cty Value so its attributes can be used in other configs. func stackConfigAsCty(stackConfig *StackConfig) (cty.Value, error) { if stackConfig == nil { return cty.NilVal, nil } output := map[string]cty.Value{} if stackConfig.Locals != nil { localsCty, err := convertToCtyWithJSON(stackConfig.Locals) if err != nil { return cty.NilVal, err } if localsCty != cty.NilVal { output[MetadataLocal] = localsCty } } // Process stacks as a map from stack name to stack config if len(stackConfig.Stacks) > 0 { stacksMap := make(map[string]cty.Value, len(stackConfig.Stacks)) for _, stack := range stackConfig.Stacks { stackCty, err := stackToCty(stack) if err != nil { return cty.NilVal, err } if stackCty != cty.NilVal { stacksMap[stack.Name] = stackCty } } if len(stacksMap) > 0 { stacksCty, err := ConvertValuesMapToCtyVal(stacksMap) if err != nil { return cty.NilVal, err } output[MetadataStack] = stacksCty } } // Process units as a map from unit name to unit config if len(stackConfig.Units) > 0 { unitsMap := make(map[string]cty.Value, len(stackConfig.Units)) for _, unit := range stackConfig.Units { unitCty, err := unitToCty(unit) if err != nil { return cty.NilVal, err } if unitCty != cty.NilVal { unitsMap[unit.Name] = unitCty } } if len(unitsMap) > 0 { unitsCty, err := ConvertValuesMapToCtyVal(unitsMap) if err != nil { return cty.NilVal, err } output[MetadataUnit] = unitsCty } } return ConvertValuesMapToCtyVal(output) } // stackToCty converts a Stack struct to a cty Value func stackToCty(stack *Stack) (cty.Value, error) { if stack == nil { return cty.NilVal, nil } output := map[string]cty.Value{ "name": gostringToCty(stack.Name), "source": gostringToCty(stack.Source), "path": gostringToCty(stack.Path), } // Handle Values if available if stack.Values != nil { output["values"] = *stack.Values } // Handle NoStack if available if stack.NoStack != nil { output["no_dot_terragrunt_stack"] = goboolToCty(*stack.NoStack) } if stack.NoValidation != nil { output["no_validation"] = goboolToCty(*stack.NoValidation) } return ConvertValuesMapToCtyVal(output) } // unitToCty converts a Unit struct to a cty Value func unitToCty(unit *Unit) (cty.Value, error) { if unit == nil { return cty.NilVal, nil } output := map[string]cty.Value{ "name": gostringToCty(unit.Name), "source": gostringToCty(unit.Source), "path": gostringToCty(unit.Path), } // Handle Values if available if unit.Values != nil { output["values"] = *unit.Values } // Handle NoStack if available if unit.NoStack != nil { output["no_dot_terragrunt_stack"] = goboolToCty(*unit.NoStack) } if unit.NoValidation != nil { output["no_validation"] = goboolToCty(*unit.NoValidation) } return ConvertValuesMapToCtyVal(output) } // Converts arbitrary go types that are json serializable to a cty Value by using json as an intermediary // representation. This avoids the strict type nature of cty, where you need to know the output type beforehand to // serialize to cty. func convertToCtyWithJSON(val any) (cty.Value, error) { jsonBytes, err := json.Marshal(val) if err != nil { return cty.NilVal, errors.New(err) } var ctyJSONVal ctyjson.SimpleJSONValue if err := ctyJSONVal.UnmarshalJSON(jsonBytes); err != nil { return cty.NilVal, errors.New(err) } return ctyJSONVal.Value, nil } // GoTypeToCty converts arbitrary go type (struct that has cty tags, slice, map with string keys, string, bool, int // uint, float, cty.Value) to a cty Value func GoTypeToCty(val any) (cty.Value, error) { // Check if the value is a map if m, ok := val.(map[string]any); ok { convertedMap := make(map[string]cty.Value) for k, v := range m { convertedValue, err := GoTypeToCty(v) if err != nil { return cty.NilVal, err } convertedMap[k] = convertedValue } return cty.ObjectVal(convertedMap), nil } // Use the existing logic for other types ctyType, err := gocty.ImpliedType(val) if err != nil { return cty.NilVal, errors.New(err) } ctyOut, err := gocty.ToCtyValue(val, ctyType) if err != nil { return cty.NilVal, errors.New(err) } return ctyOut, nil } // Converts primitive go strings to a cty Value. func gostringToCty(val string) cty.Value { ctyOut, err := gocty.ToCtyValue(val, cty.String) if err != nil { // Since we are converting primitive strings, we should never get an error in this conversion. panic(err) } return ctyOut } // Converts primitive go bools to a cty Value. func goboolToCty(val bool) cty.Value { ctyOut, err := gocty.ToCtyValue(val, cty.Bool) if err != nil { // Since we are converting primitive bools, we should never get an error in this conversion. panic(err) } return ctyOut } // FormatValue converts a primitive value to its string representation. func FormatValue(value cty.Value) (string, error) { if value.Type() == cty.String { return value.AsString(), nil } return GetValueString(value) } ================================================ FILE: pkg/config/config_as_cty_test.go ================================================ package config_test import ( "sort" "testing" "github.com/fatih/structs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) // This test makes sure that all the fields from the TerragruntConfig struct are accounted for in the conversion to // cty.Value. func TestTerragruntConfigAsCtyDrift(t *testing.T) { t.Parallel() testSource := "./foo" testTrue := true testFalse := false mockOutputs := cty.Zero mockOutputsAllowedTerraformCommands := []string{"init"} metaVal := cty.MapVal(map[string]cty.Value{ "foo": cty.StringVal("bar"), }) testConfig := config.TerragruntConfig{ Engine: &config.EngineConfig{ Source: "github.com/acme/terragrunt-plugin-custom-opentofu", Meta: &metaVal, }, Catalog: &config.CatalogConfig{ URLs: []string{ "repo/path", }, }, Terraform: &config.TerraformConfig{ Source: &testSource, ExtraArgs: []config.TerraformExtraArguments{ { Name: "init", Commands: []string{"init"}, }, }, BeforeHooks: []config.Hook{ { Name: "init", Commands: []string{"init"}, Execute: []string{"true"}, }, }, AfterHooks: []config.Hook{ { Name: "init", Commands: []string{"init"}, Execute: []string{"true"}, }, }, ErrorHooks: []config.ErrorHook{ { Name: "init", Commands: []string{"init"}, Execute: []string{"true"}, OnErrors: []string{".*"}, }, }, }, TerraformBinary: "terraform", TerraformVersionConstraint: "= 0.12.20", TerragruntVersionConstraint: "= 0.23.18", RemoteState: remotestate.New(&remotestate.Config{ BackendName: "foo", DisableInit: true, DisableDependencyOptimization: true, BackendConfig: map[string]any{ "bar": "baz", }, }), Dependencies: &config.ModuleDependencies{ Paths: []string{"foo"}, }, DownloadDir: ".terragrunt-cache", PreventDestroy: &testTrue, IamRole: "terragruntRole", Inputs: map[string]any{ "aws_region": "us-east-1", }, Locals: map[string]any{ "quote": "the answer is 42", }, TerragruntDependencies: config.Dependencies{ config.Dependency{ Name: "foo", ConfigPath: cty.StringVal("foo"), SkipOutputs: &testTrue, MockOutputs: &mockOutputs, MockOutputsAllowedTerraformCommands: &mockOutputsAllowedTerraformCommands, MockOutputsMergeWithState: &testFalse, RenderedOutputs: &mockOutputs, }, }, FeatureFlags: config.FeatureFlags{ &config.FeatureFlag{ Name: "test", Default: &cty.Zero, }, }, Errors: &config.ErrorsConfig{ Retry: []*config.RetryBlock{ { Label: "test", RetryableErrors: []string{"test"}, MaxAttempts: 0, SleepIntervalSec: 0, }, }, Ignore: []*config.IgnoreBlock{ { Label: "test", IgnorableErrors: nil, Message: "", Signals: nil, }, }, }, GenerateConfigs: map[string]codegen.GenerateConfig{ "provider": { Path: "foo", IfExists: codegen.ExistsOverwriteTerragrunt, IfExistsStr: "overwrite_terragrunt", CommentPrefix: "# ", Contents: `terraform { backend "s3" {} }`, }, }, Exclude: &config.ExcludeConfig{}, } ctyVal, err := config.TerragruntConfigAsCty(&testConfig) require.NoError(t, err) ctyMap, err := ctyhelper.ParseCtyValueToMap(ctyVal) require.NoError(t, err) // Test the root properties testConfigStructInfo := structs.New(testConfig) testConfigFields := testConfigStructInfo.Names() checked := map[string]bool{} // used to track which fields of the ctyMap were seen for _, field := range testConfigFields { mapKey, isConverted := terragruntConfigStructFieldToMapKey(t, field) if isConverted { _, hasKey := ctyMap[mapKey] assert.Truef(t, hasKey, "Struct field %s (convert of map key %s) did not convert to cty val", field, mapKey) checked[mapKey] = true } } for key := range ctyMap { _, hasKey := checked[key] assert.Truef(t, hasKey, "cty value key %s is not accounted for from struct field", key) } } // This test makes sure that all the fields in RemoteState are converted to cty func TestRemoteStateAsCtyDrift(t *testing.T) { t.Parallel() testConfig := remotestate.Config{ BackendName: "foo", DisableInit: true, DisableDependencyOptimization: true, Generate: &remotestate.ConfigGenerate{ Path: "foo", IfExists: "overwrite_terragrunt", }, BackendConfig: map[string]any{ "bar": "baz", }, Encryption: map[string]any{ "bar": "baz", }, } ctyVal, err := config.RemoteStateAsCty(remotestate.New(&testConfig)) require.NoError(t, err) ctyMap, err := ctyhelper.ParseCtyValueToMap(ctyVal) require.NoError(t, err) // Test the root properties testConfigStructInfo := structs.New(testConfig) testConfigFields := testConfigStructInfo.Names() checked := map[string]bool{} // used to track which fields of the ctyMap were seen for _, field := range testConfigFields { mapKey, isConverted := remoteStateStructFieldToMapKey(t, field) if isConverted { _, hasKey := ctyMap[mapKey] assert.Truef(t, hasKey, "Struct field %s (convert of map key %s) did not convert to cty val", field, mapKey) checked[mapKey] = true } } for key := range ctyMap { _, hasKey := checked[key] assert.Truef(t, hasKey, "cty value key %s is not accounted for from struct field", key) } } // This test makes sure that all the fields in TerraformConfig exist in ctyTerraformConfig. func TestTerraformConfigAsCtyDrift(t *testing.T) { t.Parallel() terraformConfigStructInfo := structs.New(config.TerraformConfig{}) terraformConfigFields := terraformConfigStructInfo.Names() sort.Strings(terraformConfigFields) ctyTerraformConfigStructInfo := structs.New(config.CtyTerraformConfig{}) ctyTerraformConfigFields := ctyTerraformConfigStructInfo.Names() sort.Strings(ctyTerraformConfigFields) assert.Equal(t, terraformConfigFields, ctyTerraformConfigFields) } func TestStackUnitCtyReading(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/stacks/basic/live/terragrunt.stack.hcl", nil) require.NoError(t, err) stackMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) assert.NotNil(t, stackMap) // validate parsed unit unit := stackMap["unit"].(map[string]any) assert.NotNil(t, unit) assert.NotNil(t, unit["mother"]) assert.NotNil(t, unit["father"]) assert.NotNil(t, unit["chick_1"]) assert.NotNil(t, unit["chick_2"]) } func TestStackLocalsCtyReading(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/stacks/locals/live/terragrunt.stack.hcl", nil) require.NoError(t, err) stackMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) assert.NotNil(t, stackMap) locals := stackMap["local"].(map[string]any) assert.NotNil(t, locals) } func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string, bool) { t.Helper() switch fieldName { case "Catalog": return "catalog", true case "Terraform": return "terraform", true case "TerraformBinary": return "terraform_binary", true case "TerraformVersionConstraint": return "terraform_version_constraint", true case "TerragruntVersionConstraint": return "terragrunt_version_constraint", true case "RemoteState": return "remote_state", true case "Dependencies": return "dependencies", true case "DownloadDir": return "download_dir", true case "PreventDestroy": return "prevent_destroy", true case "IamRole": return "iam_role", true case "IamAssumeRoleDuration": return "iam_assume_role_duration", true case "IamAssumeRoleSessionName": return "iam_assume_role_session_name", true case "IamWebIdentityToken": return "iam_web_identity_token", true case "Inputs": return "inputs", true case "Locals": return "locals", true case "TerragruntDependencies": return "dependency", true case "GenerateConfigs": return "generate", true case "IsPartial": return "", false case "ProcessedIncludes": return "", false case "FieldsMetadata": return "", false case "Engine": return "engine", true case "FeatureFlags": return "feature", true case "Exclude": return "exclude", true case "Errors": return "errors", true default: t.Fatalf("Unknown struct property: %s", fieldName) // This should not execute return "", false } } func remoteStateStructFieldToMapKey(t *testing.T, fieldName string) (string, bool) { t.Helper() switch fieldName { case "BackendName": return "backend", true case "DisableInit": return "disable_init", true case "DisableDependencyOptimization": return "disable_dependency_optimization", true case "Generate": return "generate", true case "BackendConfig": return "config", true case "Encryption": return "encryption", true default: t.Fatalf("Unknown struct property: %s", fieldName) // This should not execute return "", false } } ================================================ FILE: pkg/config/config_helpers.go ================================================ package config import ( "context" "encoding/json" "fmt" "io" "maps" "os" "path/filepath" "reflect" "regexp" "runtime" "slices" "strings" "sync" "unicode/utf8" "github.com/aws/aws-sdk-go-v2/aws" "github.com/getsops/sops/v3/cmd/sops/formats" "github.com/getsops/sops/v3/decrypt" "github.com/hashicorp/go-getter" "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" tflang "github.com/hashicorp/terraform/lang" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/gocty" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/locks" "github.com/gruntwork-io/terragrunt/internal/retry" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" ) const ( noMatchedPats = 1 matchedPats = 2 ) // RunCmdCacheEntry stores run_cmd results including output for replay. // This allows the output to be replayed on cache hits, which is necessary // when the command was first executed during discovery phase (with io.Discard writers) // but needs to show output during the execution phase (with real writers). type RunCmdCacheEntry struct { // Stdout is the raw stdout of the command. Stdout string // Stderr is the raw stderr of the command. Stderr string // replayOnce ensures output is replayed exactly once to a real (non-Discard) writer. replayOnce sync.Once } // Value returns the whitespace-trimmed stdout, which is the return value of run_cmd. func (e *RunCmdCacheEntry) Value() string { return strings.TrimSuffix(e.Stdout, "\n") } const ( FuncNameFindInParentFolders = "find_in_parent_folders" FuncNamePathRelativeToInclude = "path_relative_to_include" FuncNamePathRelativeFromInclude = "path_relative_from_include" FuncNameGetEnv = "get_env" FuncNameRunCmd = "run_cmd" FuncNameReadTerragruntConfig = "read_terragrunt_config" FuncNameGetPlatform = "get_platform" FuncNameGetRepoRoot = "get_repo_root" FuncNameGetPathFromRepoRoot = "get_path_from_repo_root" FuncNameGetPathToRepoRoot = "get_path_to_repo_root" FuncNameGetTerragruntDir = "get_terragrunt_dir" FuncNameGetOriginalTerragruntDir = "get_original_terragrunt_dir" FuncNameGetTerraformCommand = "get_terraform_command" FuncNameGetTerraformCLIArgs = "get_terraform_cli_args" FuncNameGetParentTerragruntDir = "get_parent_terragrunt_dir" FuncNameGetAWSAccountAlias = "get_aws_account_alias" FuncNameGetAWSAccountID = "get_aws_account_id" FuncNameGetAWSCallerIdentityArn = "get_aws_caller_identity_arn" FuncNameGetAWSCallerIdentityUserID = "get_aws_caller_identity_user_id" FuncNameGetTerraformCommandsThatNeedVars = "get_terraform_commands_that_need_vars" FuncNameGetTerraformCommandsThatNeedLocking = "get_terraform_commands_that_need_locking" FuncNameGetTerraformCommandsThatNeedInput = "get_terraform_commands_that_need_input" FuncNameGetTerraformCommandsThatNeedParallelism = "get_terraform_commands_that_need_parallelism" FuncNameSopsDecryptFile = "sops_decrypt_file" FuncNameGetTerragruntSourceCLIFlag = "get_terragrunt_source_cli_flag" FuncNameGetDefaultRetryableErrors = "get_default_retryable_errors" FuncNameReadTfvarsFile = "read_tfvars_file" FuncNameGetWorkingDir = "get_working_dir" FuncNameStartsWith = "startswith" FuncNameEndsWith = "endswith" FuncNameStrContains = "strcontains" FuncNameTimeCmp = "timecmp" FuncNameMarkAsRead = "mark_as_read" FuncNameConstraintCheck = "constraint_check" ) // TerraformCommandsNeedLocking is a list of terraform commands that accept -lock-timeout var TerraformCommandsNeedLocking = []string{ "apply", "destroy", "import", "plan", "refresh", "taint", "untaint", } // TerraformCommandsNeedVars is a list of terraform commands that accept -var or -var-file var TerraformCommandsNeedVars = []string{ "apply", "console", "destroy", "import", "plan", "push", "refresh", } // TerraformCommandsNeedInput is list of terraform commands that accept -input= var TerraformCommandsNeedInput = []string{ "apply", "import", "init", "plan", "refresh", } // TerraformCommandsNeedParallelism is a list of terraform commands that accept -parallelism= var TerraformCommandsNeedParallelism = []string{ "apply", "plan", "destroy", } type EnvVar struct { Name string DefaultValue string IsRequired bool } // TrackInclude is used to differentiate between an included config in the current parsing ctx, and an included // config that was passed through from a previous parsing ctx. type TrackInclude struct { // CurrentMap is the map version of CurrentList that maps the block labels to the included config. CurrentMap map[string]IncludeConfig // Original is used to track the original included config, and is used for resolving the include related // functions. Original *IncludeConfig // CurrentList is used to track the list of configs that should be imported and merged before the final // TerragruntConfig is returned. This preserves the order of the blocks as they appear in the config, so that we can // merge the included config in the right order. CurrentList IncludeConfigs } // Create an EvalContext for the HCL2 parser. We can define functions and variables in this ctx that the HCL2 parser // will make available to the Terragrunt configuration during parsing. func createTerragruntEvalContext(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string) (*hcl.EvalContext, error) { tfscope := tflang.Scope{ BaseDir: filepath.Dir(configPath), } terragruntFunctions := map[string]function.Function{ FuncNameFindInParentFolders: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, FindInParentFolders), FuncNamePathRelativeToInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeToInclude), FuncNamePathRelativeFromInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeFromInclude), FuncNameGetEnv: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, getEnvironmentVariable), FuncNameRunCmd: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, RunCommand), FuncNameReadTerragruntConfig: readTerragruntConfigAsFuncImpl(ctx, pctx, l), FuncNameGetPlatform: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getPlatform), FuncNameGetRepoRoot: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getRepoRoot), FuncNameGetPathFromRepoRoot: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getPathFromRepoRoot), FuncNameGetPathToRepoRoot: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getPathToRepoRoot), FuncNameGetTerragruntDir: wrapVoidToStringAsFuncImpl(ctx, pctx, l, GetTerragruntDir), FuncNameGetOriginalTerragruntDir: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getOriginalTerragruntDir), FuncNameGetTerraformCommand: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getTerraformCommand), FuncNameGetTerraformCLIArgs: wrapVoidToStringSliceAsFuncImpl(ctx, pctx, l, getTerraformCliArgs), FuncNameGetParentTerragruntDir: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, GetParentTerragruntDir), FuncNameGetAWSAccountAlias: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getAWSAccountAlias), FuncNameGetAWSAccountID: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getAWSAccountID), FuncNameGetAWSCallerIdentityArn: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getAWSCallerIdentityARN), FuncNameGetAWSCallerIdentityUserID: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getAWSCallerIdentityUserID), FuncNameGetTerraformCommandsThatNeedVars: wrapStaticValueToStringSliceAsFuncImpl(TerraformCommandsNeedVars), FuncNameGetTerraformCommandsThatNeedLocking: wrapStaticValueToStringSliceAsFuncImpl(TerraformCommandsNeedLocking), FuncNameGetTerraformCommandsThatNeedInput: wrapStaticValueToStringSliceAsFuncImpl(TerraformCommandsNeedInput), FuncNameGetTerraformCommandsThatNeedParallelism: wrapStaticValueToStringSliceAsFuncImpl(TerraformCommandsNeedParallelism), FuncNameSopsDecryptFile: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, sopsDecryptFile), FuncNameGetTerragruntSourceCLIFlag: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getTerragruntSourceCliFlag), FuncNameGetDefaultRetryableErrors: wrapVoidToStringSliceAsFuncImpl(ctx, pctx, l, getDefaultRetryableErrors), FuncNameReadTfvarsFile: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, readTFVarsFile), FuncNameGetWorkingDir: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getWorkingDir), FuncNameMarkAsRead: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, markAsRead), FuncNameConstraintCheck: wrapStringSliceToBoolAsFuncImpl(ctx, pctx, ConstraintCheck), // Map with HCL functions introduced in Terraform after v0.15.3, since upgrade to a later version is not supported // https://github.com/gruntwork-io/terragrunt/blob/master/go.mod#L22 FuncNameStartsWith: wrapStringSliceToBoolAsFuncImpl(ctx, pctx, StartsWith), FuncNameEndsWith: wrapStringSliceToBoolAsFuncImpl(ctx, pctx, EndsWith), FuncNameStrContains: wrapStringSliceToBoolAsFuncImpl(ctx, pctx, StrContains), FuncNameTimeCmp: wrapStringSliceToNumberAsFuncImpl(ctx, pctx, l, TimeCmp), } functions := map[string]function.Function{} maps.Copy(functions, tfscope.Functions()) maps.Copy(functions, terragruntFunctions) maps.Copy(functions, pctx.PredefinedFunctions) evalCtx := &hcl.EvalContext{ Functions: functions, } evalCtx.Variables = map[string]cty.Value{} if pctx.Locals != nil { evalCtx.Variables[MetadataLocal] = *pctx.Locals } if pctx.Features != nil { evalCtx.Variables[MetadataFeatureFlag] = *pctx.Features } if pctx.Values != nil { evalCtx.Variables[MetadataValues] = *pctx.Values } if pctx.DecodedDependencies != nil { evalCtx.Variables[MetadataDependency] = *pctx.DecodedDependencies } if pctx.TrackInclude != nil && len(pctx.TrackInclude.CurrentList) > 0 { // For each include block, check if we want to expose the included config, and if so, add under the include // variable. exposedInclude, err := includeMapAsCtyVal(ctx, pctx, l) if err != nil { return evalCtx, err } evalCtx.Variables[MetadataInclude] = exposedInclude } return evalCtx, nil } // Return the OS platform func getPlatform(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return runtime.GOOS, nil } // Return the repository root as an absolute path func getRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_repo_root", attrs, func(childCtx context.Context) error { var innerErr error result, innerErr = shell.GitTopLevelDir(childCtx, l, pctx.Env, pctx.WorkingDir) return innerErr }) return result, err } // Return the path from the repository root func getPathFromRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_path_from_repo_root", attrs, func(childCtx context.Context) error { repoAbsPath, innerErr := shell.GitTopLevelDir(childCtx, l, pctx.Env, pctx.WorkingDir) if innerErr != nil { return errors.New(innerErr) } repoRelPath, innerErr := filepath.Rel(repoAbsPath, pctx.WorkingDir) if innerErr != nil { return errors.New(innerErr) } result = repoRelPath return nil }) return result, err } // Return the path to the repository root func getPathToRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_path_to_repo_root", attrs, func(childCtx context.Context) error { repoAbsPath, innerErr := shell.GitTopLevelDir(childCtx, l, pctx.Env, pctx.WorkingDir) if innerErr != nil { return errors.New(innerErr) } repoRootPathAbs, innerErr := filepath.Rel(pctx.WorkingDir, repoAbsPath) if innerErr != nil { return errors.New(innerErr) } result = strings.TrimSpace(repoRootPathAbs) return nil }) return result, err } // GetTerragruntDir returns the directory where the Terragrunt configuration file lives. func GetTerragruntDir(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_terragrunt_dir", attrs, func(childCtx context.Context) error { result = filepath.Dir(pctx.TerragruntConfigPath) return nil }) return result, err } // Return the directory where the original Terragrunt configuration file lives. This is primarily useful when one // Terragrunt config is being read from another e.g., if /terraform-code/terragrunt.hcl // calls read_terragrunt_config("/foo/bar.hcl"), and within bar.hcl, you call get_original_terragrunt_dir(), you'll // get back /terraform-code. func getOriginalTerragruntDir(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_original_terragrunt_dir", attrs, func(childCtx context.Context) error { result = filepath.Dir(pctx.OriginalTerragruntConfigPath) return nil }) return result, err } // GetParentTerragruntDir returns the parent directory where the Terragrunt configuration file lives. func GetParentTerragruntDir(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(params) > 0 { attrs["include_label"] = params[0] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_parent_terragrunt_dir", attrs, func(childCtx context.Context) error { parentPath, innerErr := PathRelativeFromInclude(childCtx, pctx, l, params) if innerErr != nil { return errors.New(innerErr) } currentPath := filepath.Dir(pctx.TerragruntConfigPath) parentPath = filepath.Clean(filepath.Join(currentPath, parentPath)) result = parentPath return nil }) return result, err } func parseGetEnvParameters(parameters []string) (EnvVar, error) { envVariable := EnvVar{} switch len(parameters) { case noMatchedPats: envVariable.IsRequired = true envVariable.Name = parameters[0] case matchedPats: envVariable.Name = parameters[0] envVariable.DefaultValue = parameters[1] default: return envVariable, errors.New(InvalidGetEnvParamsError{ActualNumParams: len(parameters), Example: `getEnv("", "[DEFAULT]")`}) } if envVariable.Name == "" { return envVariable, errors.New(InvalidEnvParamNameError{EnvName: parameters[0]}) } return envVariable, nil } // RunCommand is a helper function that runs a command and returns the stdout as the interpolation // for each `run_cmd` in locals section, function is called twice // result func RunCommand(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { // Capture original args for telemetry before any modifications originalArgs := make([]string, len(args)) copy(originalArgs, args) // Parse flags for telemetry attributes suppressOutput := false disableCache := false useGlobalCache := false for _, arg := range args { switch arg { case "--terragrunt-quiet": suppressOutput = true case "--terragrunt-global-cache": useGlobalCache = true case "--terragrunt-no-cache": disableCache = true } } // Extract command name (first non-flag argument) var command string for _, arg := range args { if !strings.HasPrefix(arg, "--terragrunt-") { command = arg break } } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_run_cmd", map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, "command": command, "args": fmt.Sprintf("%v", originalArgs), "suppress_output": suppressOutput, "global_cache": useGlobalCache, "no_cache": disableCache, }, func(childCtx context.Context) error { var innerErr error result, innerErr = runCommandImpl(childCtx, pctx, l, args) return innerErr }) return result, err } // runCommandImpl contains the actual implementation of RunCommand func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { // runCommandCache - cache of evaluated `run_cmd` invocations // see: https://github.com/gruntwork-io/terragrunt/issues/1427 runCommandCache := cache.ContextCache[*RunCmdCacheEntry](ctx, RunCmdCacheContextKey) if len(args) == 0 { return "", errors.New(EmptyStringNotAllowedError("parameter to the run_cmd function")) } suppressOutput := false disableCache := false useGlobalCache := false currentPath := filepath.Dir(pctx.TerragruntConfigPath) cachePath := currentPath checkOptions := true for checkOptions && len(args) > 0 { switch args[0] { case "--terragrunt-quiet": suppressOutput = true args = slices.Delete(args, 0, 1) case "--terragrunt-global-cache": if disableCache { return "", errors.New(ConflictingRunCmdCacheOptionsError{}) } useGlobalCache = true cachePath = "_global_" args = slices.Delete(args, 0, 1) case "--terragrunt-no-cache": if useGlobalCache { return "", errors.New(ConflictingRunCmdCacheOptionsError{}) } disableCache = true args = slices.Delete(args, 0, 1) default: checkOptions = false } } // To avoid re-run of the same run_cmd command, is used in memory cache for command results, with caching key path + arguments // see: https://github.com/gruntwork-io/terragrunt/issues/1427 cacheKey := fmt.Sprintf("%v-%v", cachePath, args) // Skip cache lookup if --terragrunt-no-cache is set if !disableCache { cachedEntry, foundInCache := runCommandCache.Get(ctx, cacheKey) if foundInCache { // Replay stdout/stderr to current writers once when we have a real (non-Discard) writer. // This is needed because the command may have first run during discovery phase // with io.Discard writers, so we need to replay the output during execution phase. // We only call Do() when we have a real writer, so it won't fire during discovery. if pctx.Writers.Writer != io.Discard { cachedEntry.replayOnce.Do(func() { if !suppressOutput && cachedEntry.Stdout != "" { _, _ = pctx.Writers.Writer.Write([]byte(cachedEntry.Stdout)) } if cachedEntry.Stderr != "" { _, _ = pctx.Writers.ErrWriter.Write([]byte(cachedEntry.Stderr)) } }) } if suppressOutput { l.Debugf("run_cmd, cached output: [REDACTED]") } else { l.Debugf("run_cmd, cached output: [%s]", cachedEntry.Value()) } return cachedEntry.Value(), nil } } cmdOutput, err := shell.RunCommandWithOutput( ctx, l, shellRunOptsFromPctx(pctx), currentPath, true, false, args[0], args[1:]..., ) if err != nil { return "", errors.New(err) } value := strings.TrimSuffix(cmdOutput.Stdout.String(), "\n") if suppressOutput { l.Debugf("run_cmd output: [REDACTED]") } else { l.Debugf("run_cmd output: [%s]", value) } entry := &RunCmdCacheEntry{ Stdout: cmdOutput.Stdout.String(), Stderr: cmdOutput.Stderr.String(), } if pctx.Writers.Writer != io.Discard { entry.replayOnce.Do(func() { if !suppressOutput && entry.Stdout != "" { _, _ = pctx.Writers.Writer.Write([]byte(entry.Stdout)) } if entry.Stderr != "" { _, _ = pctx.Writers.ErrWriter.Write([]byte(entry.Stderr)) } }) } if !disableCache { runCommandCache.Put(ctx, cacheKey, entry) } return value, nil } func getEnvironmentVariable(ctx context.Context, pctx *ParsingContext, l log.Logger, parameters []string) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(parameters) > 0 { attrs["env_name"] = parameters[0] } if len(parameters) > 1 { attrs["default_value"] = parameters[1] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_env", attrs, func(childCtx context.Context) error { parameterMap, innerErr := parseGetEnvParameters(parameters) if innerErr != nil { return errors.New(innerErr) } envValue, exists := pctx.Env[parameterMap.Name] if !exists { if parameterMap.IsRequired { return errors.New(EnvVarNotFoundError{EnvVar: parameterMap.Name}) } envValue = parameterMap.DefaultValue } result = envValue return nil }) return result, err } // FindInParentFolders fings a parent Terragrunt configuration file in the parent // folders above the current Terragrunt configuration file and return its path. func FindInParentFolders( ctx context.Context, pctx *ParsingContext, l log.Logger, params []string, ) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(params) > 0 { attrs["file_to_find"] = params[0] } if len(params) > 1 { attrs["fallback"] = params[1] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_find_in_parent_folders", attrs, func(childCtx context.Context) error { var innerErr error result, innerErr = findInParentFoldersImpl(childCtx, pctx, l, params) return innerErr }) return result, err } // findInParentFoldersImpl contains the actual implementation of FindInParentFolders func findInParentFoldersImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { numParams := len(params) var ( fileToFindParam string fallbackParam string ) if numParams > 0 { fileToFindParam = params[0] } if numParams > 1 { fallbackParam = params[1] } if numParams > matchedPats { return "", errors.New(WrongNumberOfParamsError{Func: "find_in_parent_folders", Expected: "0, 1, or 2", Actual: numParams}) } previousDir := filepath.Dir(pctx.TerragruntConfigPath) if fileToFindParam == "" || fileToFindParam == DefaultTerragruntConfigPath { allControls := pctx.StrictControls rootTGHCLControl := allControls.FilterByNames(controls.RootTerragruntHCL) logger := log.ContextWithLogger(ctx, l) if err := rootTGHCLControl.Evaluate(logger); err != nil { return "", clihelper.NewExitError(err, clihelper.ExitCodeGeneralError) } } // The strict control above will make this function return an error when no parameter is passed. // When this becomes a breaking change, we can remove the strict control and // do some validation here to ensure that users aren't using "terragrunt.hcl" as the root of their Terragrunt // configurations. fileToFindStr := DefaultTerragruntConfigPath if fileToFindParam != "" { fileToFindStr = fileToFindParam } // To avoid getting into an accidental infinite loop (e.g. do to cyclical symlinks), set a max on the number of // parent folders we'll check for range pctx.MaxFoldersToCheck { currentDir := filepath.Dir(previousDir) if currentDir == previousDir { if numParams == matchedPats { return fallbackParam, nil } return "", errors.New(ParentFileNotFoundError{ Path: pctx.TerragruntConfigPath, File: fileToFindStr, Cause: "Traversed all the way to the root", }) } fileToFind := GetDefaultConfigPath(currentDir) if fileToFindParam != "" { fileToFind = filepath.Join(currentDir, fileToFindParam) } if util.FileExists(fileToFind) { return fileToFind, nil } previousDir = currentDir } return "", errors.New(ParentFileNotFoundError{ Path: pctx.TerragruntConfigPath, File: fileToFindStr, Cause: fmt.Sprintf("Exceeded maximum folders to check (%d)", pctx.MaxFoldersToCheck), }) } // PathRelativeToInclude returns the relative path between the included Terragrunt configuration file // and the current Terragrunt configuration file. Name param is required and used to lookup the // relevant import block when called in a child config with multiple import blocks. func PathRelativeToInclude(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(params) > 0 { attrs["include_label"] = params[0] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_path_relative_to_include", attrs, func(childCtx context.Context) error { if pctx.TrackInclude == nil { result = "." return nil } var included IncludeConfig switch { case pctx.TrackInclude.Original != nil: included = *pctx.TrackInclude.Original case len(pctx.TrackInclude.CurrentList) > 0: // Called in child ctx, so we need to select the right include file. selected, innerErr := getSelectedIncludeBlock(*pctx.TrackInclude, params) if innerErr != nil { return innerErr } included = *selected default: result = "." return nil } currentPath := filepath.Dir(pctx.TerragruntConfigPath) includePath := filepath.Dir(included.Path) if !filepath.IsAbs(includePath) { includePath = filepath.Join(currentPath, includePath) } var innerErr error result, innerErr = util.GetPathRelativeTo(currentPath, includePath) return innerErr }) return result, err } // PathRelativeFromInclude returns the relative path from the current Terragrunt configuration to the included Terragrunt configuration file func PathRelativeFromInclude(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(params) > 0 { attrs["include_label"] = params[0] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_path_relative_from_include", attrs, func(childCtx context.Context) error { if pctx.TrackInclude == nil { result = "." return nil } included, innerErr := getSelectedIncludeBlock(*pctx.TrackInclude, params) if innerErr != nil { return innerErr } else if included == nil { result = "." return nil } includePath := filepath.Dir(included.Path) currentPath := filepath.Dir(pctx.TerragruntConfigPath) if !filepath.IsAbs(includePath) { includePath = filepath.Join(currentPath, includePath) } result, innerErr = util.GetPathRelativeTo(includePath, currentPath) return innerErr }) return result, err } // getTerraformCommand returns the current terraform command in execution func getTerraformCommand(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return pctx.TerraformCommand, nil } // getWorkingDir returns the current working dir func getWorkingDir(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_working_dir", attrs, func(childCtx context.Context) error { var innerErr error result, innerErr = getWorkingDirImpl(childCtx, pctx, l) return innerErr }) return result, err } // getWorkingDirImpl contains the actual implementation of getWorkingDir func getWorkingDirImpl(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { l.Debugf("Start processing get_working_dir built-in function") defer l.Debugf("Complete processing get_working_dir built-in function") // Initialize evaluation ctx extensions from base blocks. pctx.PredefinedFunctions = map[string]function.Function{ FuncNameGetWorkingDir: wrapVoidToEmptyStringAsFuncImpl(), } terragruntConfig, err := ParseConfigFile(ctx, pctx, l, pctx.TerragruntConfigPath, nil) if err != nil { return "", err } sourceURL, err := GetTerraformSourceURL(pctx.Source, pctx.SourceMap, pctx.OriginalTerragruntConfigPath, terragruntConfig) if err != nil { return "", err } // sourceURL will always be at least "." (current directory) to ensure cache is always used walkWithSymlinks := pctx.Experiments.Evaluate(experiment.Symlinks) source, err := tf.NewSource(l, sourceURL, pctx.DownloadDir, pctx.WorkingDir, walkWithSymlinks) if err != nil { return "", err } return source.WorkingDir, nil } // getTerraformCliArgs returns cli args for terraform func getTerraformCliArgs(ctx context.Context, pctx *ParsingContext, l log.Logger) ([]string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result []string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_terraform_cli_args", attrs, func(childCtx context.Context) error { if pctx.TerraformCliArgs != nil { result = pctx.TerraformCliArgs.Slice() } return nil }) return result, err } // getDefaultRetryableErrors returns default retryable errors for use in errors.retry blocks func getDefaultRetryableErrors(ctx context.Context, pctx *ParsingContext, l log.Logger) ([]string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result []string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_default_retryable_errors", attrs, func(childCtx context.Context) error { result = retry.DefaultRetryableErrors return nil }) return result, err } // getAWSField is a common helper for fetching a single AWS field with telemetry. // It builds an AWS config from the parsing context, then calls fetchFn to get the value. func getAWSField(ctx context.Context, pctx *ParsingContext, l log.Logger, telemetryName string, fetchFn func(context.Context, *aws.Config) (string, error)) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, telemetryName, attrs, func(childCtx context.Context) error { awsConfig, err := awshelper.NewAWSConfigBuilder(). WithEnv(pctx.Env). WithIAMRoleOptions(pctx.IAMRoleOptions). Build(childCtx, l) if err != nil { return err } val, err := fetchFn(childCtx, &awsConfig) if err != nil { return err } result = val return nil }) return result, err } func getAWSAccountAlias(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return getAWSField(ctx, pctx, l, "hcl_fn_get_aws_account_alias", awshelper.GetAWSAccountAlias) } func getAWSAccountID(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return getAWSField(ctx, pctx, l, "hcl_fn_get_aws_account_id", awshelper.GetAWSAccountID) } func getAWSCallerIdentityARN(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return getAWSField(ctx, pctx, l, "hcl_fn_get_aws_caller_identity_arn", awshelper.GetAWSIdentityArn) } func getAWSCallerIdentityUserID(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { return getAWSField(ctx, pctx, l, "hcl_fn_get_aws_caller_identity_user_id", awshelper.GetAWSUserID) } // ParseTerragruntConfig parses the terragrunt config and return a // representation that can be used as a reference. If given a default value, // this will return the default if the terragrunt config file does not exist. func ParseTerragruntConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string, defaultVal *cty.Value) (cty.Value, error) { // target config check: make sure the target config exists. If the file does not exist, and there is no default val, // return an error. If the file does not exist but there is a default val, return the default val. Otherwise, // proceed to parse the file as a terragrunt config file. targetConfig := getCleanedTargetConfigPath(configPath, pctx.TerragruntConfigPath) targetConfigFileExists := util.FileExists(targetConfig) if !targetConfigFileExists && defaultVal == nil { return cty.NilVal, errors.New(TerragruntConfigNotFoundError{Path: targetConfig}) } if !targetConfigFileExists { return *defaultVal, nil } path := targetConfig if !filepath.IsAbs(path) { path = filepath.Join(pctx.WorkingDir, path) path = filepath.Clean(path) } // Track that this file was read during parsing trackFileRead(pctx.FilesRead, path) // We update the ctx of terragruntOptions to the config being read in. l, pctx, err := pctx.WithConfigPath(l, targetConfig) if err != nil { return cty.NilVal, err } pctx = pctx.WithDiagnosticsSuppressed(l) // check if file is stack file, decode as stack file if filepath.Base(targetConfig) == DefaultStackFile { stackSourceDir := filepath.Dir(targetConfig) values, readErr := ReadValues(ctx, pctx, l, stackSourceDir) if readErr != nil { return cty.NilVal, errors.Errorf("failed to read values from directory %s: %v", stackSourceDir, readErr) } stackFile, readErr := ReadStackConfigFile(ctx, l, pctx, targetConfig, values) if readErr != nil { return cty.NilVal, errors.New(readErr) } return stackConfigAsCty(stackFile) } // check if file is a values file, decode as values file if strings.HasSuffix(targetConfig, valuesFile) { unitValues, readErr := ReadValues(ctx, pctx, l, filepath.Dir(targetConfig)) if readErr != nil { return cty.NilVal, errors.New(readErr) } return *unitValues, nil } config, err := ParseConfigFile(ctx, pctx, l, targetConfig, nil) if err != nil { return cty.NilVal, err } // We have to set the rendered outputs here because ParseConfigFile will not do so on the TerragruntConfig. The // outputs are stored in a special map that is used only for rendering and thus is not available when we try to // serialize the config for consumption. // NOTE: this will not call terragrunt output, since all the values are cached from the ParseConfigFile call // NOTE: we don't use range here because range will copy the slice, thereby undoing the set attribute. for i := range len(config.TerragruntDependencies) { err := config.TerragruntDependencies[i].setRenderedOutputs(ctx, pctx, l) if err != nil { return cty.NilVal, errors.New(err) } } return TerragruntConfigAsCty(config) } // Create a cty Function that can be used to for calling read_terragrunt_config. func readTerragruntConfigAsFuncImpl(ctx context.Context, pctx *ParsingContext, l log.Logger) function.Function { return function.New(&function.Spec{ // Takes one required string param Params: []function.Parameter{{Type: cty.String}}, // And optional param that takes anything VarParam: &function.Parameter{Type: cty.DynamicPseudoType}, // We don't know the return type until we parse the terragrunt config, so we use a dynamic type Type: function.StaticReturnType(cty.DynamicPseudoType), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { numParams := len(args) if numParams == 0 || numParams > 2 { return cty.NilVal, errors.New(WrongNumberOfParamsError{Func: "read_terragrunt_config", Expected: "1 or 2", Actual: numParams}) } strArgs, err := ctySliceToStringSlice(args[:1]) if err != nil { return cty.NilVal, err } var defaultVal *cty.Value = nil if numParams == matchedPats { defaultVal = &args[1] } targetConfigPath := strArgs[0] attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, "target_config_path": targetConfigPath, "has_default": defaultVal != nil, } var result cty.Value err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_read_terragrunt_config", attrs, func(childCtx context.Context) error { var innerErr error result, innerErr = ParseTerragruntConfig(childCtx, pctx, l, targetConfigPath, defaultVal) return innerErr }) return result, err }, }) } // Returns a cleaned path to the target config (the `terragrunt.hcl` or `terragrunt.hcl.json` file), handling relative // paths correctly. This will automatically append `terragrunt.hcl` or `terragrunt.hcl.json` to the path if the target // path is a directory. func getCleanedTargetConfigPath(configPath string, workingPath string) string { cwd := filepath.Dir(workingPath) targetConfig := configPath if !filepath.IsAbs(targetConfig) { targetConfig = filepath.Join(cwd, targetConfig) } if util.IsDir(targetConfig) { targetConfig = GetDefaultConfigPath(targetConfig) } return filepath.Clean(targetConfig) } // GetTerragruntSourceForModule returns the source path for a module based on the source path of the parent module and the // source path specified in the module's terragrunt.hcl file. // // If one of the xxx-all commands is called with the --source parameter, then for each module, we need to // build its own --source parameter by doing the following: // // 1. Read the source URL from the Terragrunt configuration of each module // 2. Extract the path from that URL (the part after a double-slash) // 3. Append the path to the --source parameter // // Example: // // --source: /source/infrastructure-modules // source param in module's terragrunt.hcl: git::git@github.com:acme/infrastructure-modules.git//networking/vpc?ref=v0.0.1 // // This method will return: /source/infrastructure-modules//networking/vpc func GetTerragruntSourceForModule(sourcePath string, modulePath string, moduleTerragruntConfig *TerragruntConfig) (string, error) { if sourcePath == "" || moduleTerragruntConfig.Terraform == nil || moduleTerragruntConfig.Terraform.Source == nil || *moduleTerragruntConfig.Terraform.Source == "" { return "", nil } // use go-getter to split the module source string into a valid URL and subdirectory (if // is present) moduleURL, moduleSubdir := getter.SourceDirSubdir(*moduleTerragruntConfig.Terraform.Source) // if both URL and subdir are missing, something went terribly wrong if moduleURL == "" && moduleSubdir == "" { return "", errors.New(InvalidSourceURLError{ ModulePath: modulePath, ModuleSourceURL: *moduleTerragruntConfig.Terraform.Source, TerragruntSource: sourcePath, }) } // if only subdir is missing, check if we can obtain a valid module name from the URL portion if moduleURL != "" && moduleSubdir == "" { moduleSubdirFromURL, err := getModulePathFromSourceURL(moduleURL) if err != nil { return moduleSubdirFromURL, err } return util.JoinTerraformModulePath(sourcePath, moduleSubdirFromURL), nil } return util.JoinTerraformModulePath(sourcePath, moduleSubdir), nil } // Parse sourceUrl not containing '//', and attempt to obtain a module path. // Example: // // sourceUrl = "git::ssh://git@ghe.ourcorp.com/OurOrg/module-name.git" // will return "module-name". func getModulePathFromSourceURL(sourceURL string) (string, error) { // Regexp for module name extraction. It assumes that the query string has already been stripped off. // Then we simply capture anything after the last slash, and before `.` or end of string. var moduleNameRegexp = regexp.MustCompile(`(?:.+/)(.+?)(?:\.|$)`) // strip off the query string if present sourceURL = strings.Split(sourceURL, "?")[0] matches := moduleNameRegexp.FindStringSubmatch(sourceURL) // if regexp returns less/more than the full match + 1 capture group, then something went wrong with regex (invalid source string) if len(matches) != matchedPats { return "", errors.New(ParsingModulePathError{ModuleSourceURL: sourceURL}) } return matches[1], nil } // decrypts and returns sops encrypted utf-8 yaml or json data as a string func sopsDecryptFile(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { var sourceFile string if len(params) > 0 { sourceFile = params[0] } attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, "file_path": sourceFile, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_sops_decrypt_file", attrs, func(childCtx context.Context) error { if len(params) != 1 { return errors.New(WrongNumberOfParamsError{Func: "sops_decrypt_file", Expected: "1", Actual: len(params)}) } format, err := getSopsFileFormat(sourceFile) if err != nil { return errors.New(err) } path := sourceFile if !filepath.IsAbs(path) { path = filepath.Join(pctx.WorkingDir, path) path = filepath.Clean(path) } trackFileRead(pctx.FilesRead, path) var innerErr error result, innerErr = sopsDecryptFileImpl(childCtx, pctx, l, path, format, decrypt.File) return innerErr }) return result, err } // sopsDecryptFileImpl contains the actual implementation of sopsDecryptFile func sopsDecryptFileImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, path string, format string, decryptFn func(string, string) ([]byte, error)) (string, error) { sopsCache := cache.ContextCache[string](ctx, SopsCacheContextKey) // Fast path: check cache before acquiring lock. // Cache has its own sync.RWMutex, safe for concurrent reads. if val, ok := sopsCache.Get(ctx, path); ok { l.Debugf("sops decrypt: cache hit for %s (len=%d)", path, len(val)) return val, nil } // Cache miss: acquire lock for env mutation + decrypt. // The lock serializes os.Setenv/os.Unsetenv to prevent race conditions // when multiple units decrypt concurrently with different auth credentials. // See https://github.com/gruntwork-io/terragrunt/issues/5515 l.Debugf("sops decrypt: cache miss, acquiring lock for %s (format=%s)", path, format) locks.EnvLock.Lock() defer locks.EnvLock.Unlock() // Double-check: another goroutine may have populated cache while we waited for the lock. if val, ok := sopsCache.Get(ctx, path); ok { l.Debugf("sops decrypt: cache hit after lock for %s (len=%d)", path, len(val)) return val, nil } // Set env vars from opts.Env that are missing from process env. // Auth-provider credentials (e.g., AWS_SESSION_TOKEN) may not exist // in process env yet — SOPS needs them for KMS auth. // Existing process env vars are preserved to avoid overriding real // credentials with empty auth-provider values. env := pctx.Env setKeys := make([]string, 0, len(env)) for k, v := range env { if _, exists := os.LookupEnv(k); exists { continue } os.Setenv(k, v) //nolint:errcheck setKeys = append(setKeys, k) } defer func() { for _, k := range setKeys { os.Unsetenv(k) //nolint:errcheck } }() l.Debugf("sops decrypt: decrypting %s", path) rawData, err := decryptFn(path, format) if err != nil { return "", errors.New(extractSopsErrors(err)) } if utf8.Valid(rawData) { value := string(rawData) sopsCache.Put(ctx, path, value) return value, nil } return "", errors.New(InvalidSopsFormatError{SourceFilePath: path}) } // Mapping of SOPS format to string var sopsFormatToString = map[formats.Format]string{ formats.Binary: "binary", formats.Dotenv: "dotenv", formats.Ini: "ini", formats.Json: "json", formats.Yaml: "yaml", } // getSopsFileFormat - Return file format for SOPS library func getSopsFileFormat(sourceFile string) (string, error) { fileFormat := formats.FormatForPath(sourceFile) format, found := sopsFormatToString[fileFormat] if !found { return "", InvalidSopsFormatError{SourceFilePath: sourceFile} } return format, nil } // Return the location of the Terraform files provided via --source func getTerragruntSourceCliFlag(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_get_terragrunt_source_cli_flag", attrs, func(childCtx context.Context) error { result = pctx.Source return nil }) return result, err } // Return the selected include block based on a label passed in as a function param. Note that the assumption is that: // - If the Original attribute is set, we are in the parent ctx so return that. // - If there are no include blocks, no param is required and nil is returned. // - If there is only one include block, no param is required and that is automatically returned. // - If there is more than one include block, 1 param is required to use as the label name to lookup the include block // to use. func getSelectedIncludeBlock(trackInclude TrackInclude, params []string) (*IncludeConfig, error) { importMap := trackInclude.CurrentMap if trackInclude.Original != nil { return trackInclude.Original, nil } if len(importMap) == 0 { return nil, nil } if len(importMap) == 1 { for _, val := range importMap { return &val, nil } } numParams := len(params) if numParams != 1 { return nil, errors.New(WrongNumberOfParamsError{Func: "path_relative_from_include", Expected: "1", Actual: numParams}) } importName := params[0] imported, hasKey := importMap[importName] if !hasKey { return nil, errors.New(InvalidIncludeKeyError{name: importName}) } return &imported, nil } // StartsWith Implementation of Terraform's StartsWith function // //nolint:dupl func StartsWith(ctx context.Context, pctx *ParsingContext, args []string) (bool, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["str"] = args[0] } if len(args) > 1 { attrs["prefix"] = args[1] } var result bool err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_startswith", attrs, func(childCtx context.Context) error { if len(args) == 0 { return errors.New(EmptyStringNotAllowedError("parameter to the startswith function")) } str := args[0] prefix := args[1] result = strings.HasPrefix(str, prefix) return nil }) return result, err } // EndsWith Implementation of Terraform's EndsWith function // //nolint:dupl func EndsWith(ctx context.Context, pctx *ParsingContext, args []string) (bool, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["str"] = args[0] } if len(args) > 1 { attrs["suffix"] = args[1] } var result bool err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_endswith", attrs, func(childCtx context.Context) error { if len(args) == 0 { return errors.New(EmptyStringNotAllowedError("parameter to the endswith function")) } str := args[0] suffix := args[1] result = strings.HasSuffix(str, suffix) return nil }) return result, err } // TimeCmp implements Terraform's `timecmp` function that compares two timestamps. func TimeCmp(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (int64, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["timestamp_a"] = args[0] } if len(args) > 1 { attrs["timestamp_b"] = args[1] } var result int64 err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_timecmp", attrs, func(childCtx context.Context) error { if len(args) != matchedPats { return errors.New(errors.New("function can take only two parameters: timestamp_a and timestamp_b")) } tsA, innerErr := util.ParseTimestamp(args[0]) if innerErr != nil { return errors.New(fmt.Errorf("could not parse first parameter %q: %w", args[0], innerErr)) } tsB, innerErr := util.ParseTimestamp(args[1]) if innerErr != nil { return errors.New(fmt.Errorf("could not parse second parameter %q: %w", args[1], innerErr)) } switch { case tsA.Equal(tsB): result = 0 case tsA.Before(tsB): result = -1 default: // By elimination, tsA must be after tsB. result = 1 } return nil }) return result, err } // StrContains Implementation of Terraform's StrContains function // //nolint:dupl func StrContains(ctx context.Context, pctx *ParsingContext, args []string) (bool, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["str"] = args[0] } if len(args) > 1 { attrs["substr"] = args[1] } var result bool err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_strcontains", attrs, func(childCtx context.Context) error { if len(args) == 0 { return errors.New(EmptyStringNotAllowedError("parameter to the strcontains function")) } str := args[0] substr := args[1] result = strings.Contains(str, substr) return nil }) return result, err } // readTFVarsFile reads a *.tfvars or *.tfvars.json file and returns the contents as a JSON encoded string func readTFVarsFile(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { var filePath string if len(args) > 0 { filePath = args[0] } attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, "file_path": filePath, } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_read_tfvars_file", attrs, func(childCtx context.Context) error { var innerErr error result, innerErr = readTFVarsFileImpl(pctx, l, args) return innerErr }) return result, err } // readTFVarsFileImpl contains the actual implementation of readTFVarsFile func readTFVarsFileImpl(pctx *ParsingContext, l log.Logger, args []string) (string, error) { if len(args) != 1 { return "", errors.New(WrongNumberOfParamsError{Func: "read_tfvars_file", Expected: "1", Actual: len(args)}) } varFile := args[0] if !filepath.IsAbs(varFile) { varFile = filepath.Join(pctx.WorkingDir, varFile) varFile = filepath.Clean(varFile) } if !util.FileExists(varFile) { return "", errors.New(TFVarFileNotFoundError{File: varFile}) } // Track that this file was read during parsing trackFileRead(pctx.FilesRead, varFile) fileContents, err := os.ReadFile(varFile) if err != nil { return "", errors.New(fmt.Errorf("could not read file %q: %w", varFile, err)) } if strings.HasSuffix(varFile, "json") { var variables map[string]any // just want to be sure that the file is valid json if err := json.Unmarshal(fileContents, &variables); err != nil { return "", errors.New(fmt.Errorf("could not unmarshal json body of tfvar file: %w", err)) } return string(fileContents), nil } var variables map[string]any if err := ParseAndDecodeVarFile(l, varFile, fileContents, &variables); err != nil { return "", err } data, err := json.Marshal(variables) if err != nil { return "", errors.New(fmt.Errorf("could not marshal json body of tfvar file: %w", err)) } return string(data), nil } // markAsRead marks a file as explicitly read. This is useful for detection via TerragruntUnitsReading flag. func markAsRead(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["file_path"] = args[0] } var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_mark_as_read", attrs, func(childCtx context.Context) error { if len(args) != 1 { return errors.New(WrongNumberOfParamsError{Func: "mark_as_read", Expected: "1", Actual: len(args)}) } file := args[0] // Copy the file path to avoid modifying the original. // This is necessary so that the HCL function doesn't // return a different value than the original file path. path := file if !filepath.IsAbs(path) { path = filepath.Join(pctx.WorkingDir, path) path = filepath.Clean(path) } // Track that this file was read during parsing trackFileRead(pctx.FilesRead, path) result = file return nil }) return result, err } // warnWhenFileNotMarkedAsRead warns when a file is not being marked as read, even though a user might expect it to be. // Situations where this is the case include: // - A user specifies a file in the UnitsReading flag and that file is being read while parsing the inputs attribute. // // When the file is not marked as read, the function will return true, otherwise false. // ParseAndDecodeVarFile uses the HCL2 file to parse the given varfile string into an HCL file body, and then decode it // into the provided output. func ParseAndDecodeVarFile(l log.Logger, varFile string, fileContents []byte, out any) error { parser := hclparse.NewParser(hclparse.WithLogger(l)) file, err := parser.ParseFromBytes(fileContents, varFile) if err != nil { return err } attrs, err := file.JustAttributes() if err != nil { return err } valMap := map[string]cty.Value{} for _, attr := range attrs { val, err := attr.Value(nil) // nil because no function calls or variable references are allowed here if err != nil { return err } valMap[attr.Name] = val } ctyVal, err := ConvertValuesMapToCtyVal(valMap) if err != nil { return err } if ctyVal.IsNull() { // If the file is empty, doesn't make sense to do conversion return nil } typedOut, hasType := out.(*map[string]any) if hasType { genericMap, err := ctyhelper.ParseCtyValueToMap(ctyVal) if err != nil { return err } *typedOut = genericMap return nil } return gocty.FromCtyValue(ctyVal, out) } // extractSopsErrors extracts the original errors from the sops library and returns them as a errors.MultiError. func extractSopsErrors(err error) *errors.MultiError { var errs = &errors.MultiError{} // workaround to extract original errors from sops library // using reflection extract GroupResults from getDataKeyError // may not be compatible with future versions errValue := reflect.ValueOf(err) if errValue.Kind() == reflect.Pointer { errValue = errValue.Elem() } if errValue.Type().Name() == "getDataKeyError" { groupResultsField := errValue.FieldByName("GroupResults") if groupResultsField.IsValid() && groupResultsField.Kind() == reflect.Slice { for i := range groupResultsField.Len() { groupErr := groupResultsField.Index(i) if groupErr.CanInterface() { if err, ok := groupErr.Interface().(error); ok { errs = errs.Append(err) } } } } } // append the original error if no group results were found if errs.Len() == 0 { errs = errs.Append(err) } return errs } // ConstraintCheck Implementation of Terraform's StartsWith function func ConstraintCheck(ctx context.Context, pctx *ParsingContext, args []string) (bool, error) { attrs := map[string]any{ "config_path": pctx.TerragruntConfigPath, "working_dir": pctx.WorkingDir, } if len(args) > 0 { attrs["version"] = args[0] } if len(args) > 1 { attrs["constraint"] = args[1] } var result bool err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_constraint_check", attrs, func(childCtx context.Context) error { if len(args) != matchedPats { return errors.New(WrongNumberOfParamsError{Func: FuncNameConstraintCheck, Expected: "2", Actual: len(args)}) } v, innerErr := version.NewSemver(args[0]) if innerErr != nil { return errors.Errorf("invalid version %s: %w", args[0], innerErr) } c, innerErr := version.NewConstraint(args[1]) if innerErr != nil { return errors.Errorf("invalid constraint %s: %w", args[1], innerErr) } result = c.Check(v) return nil }) return result, err } // trackFileRead adds a file path to the FilesRead slice if it's not already present. // This prevents duplicate entries when the same file is read multiple times during parsing. func trackFileRead(filesRead *[]string, path string) { if filesRead == nil { return } if slices.Contains(*filesRead, path) { return } *filesRead = append(*filesRead, path) } ================================================ FILE: pkg/config/config_helpers_test.go ================================================ package config_test import ( "context" "fmt" "os" "path/filepath" "reflect" "runtime" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/puzpuzpuz/xsync/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) // assertErrorType checks that the error chain contains an error of the same type as expectedErr. func assertErrorType(t *testing.T, expectedErr, actualErr error) bool { t.Helper() expectedType := reflect.TypeOf(expectedErr) for err := actualErr; err != nil; err = errors.Unwrap(err) { if reflect.TypeOf(err) == expectedType { return true } } return assert.Fail(t, "error type mismatch", "expected error of type %T in chain, but got %T", expectedErr, actualErr) } func TestPathRelativeToInclude(t *testing.T) { t.Parallel() testCases := []struct { include map[string]config.IncludeConfig configPath string expectedPath string params []string }{ { configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: ".", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: "child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: "child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: "child/sub-child/sub-sub-child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: "child/sub-child/sub-sub-child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../child/sub-child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "child/sub-child", }, { include: map[string]config.IncludeConfig{ "root": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}, "child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}, }, params: []string{"child"}, configPath: filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../child/sub-child", }, } for _, tc := range testCases { trackInclude := getTrackIncludeFromTestData(tc.include, tc.params) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) pctx = pctx.WithTrackInclude(trackInclude) actualPath, actualErr := config.PathRelativeToInclude(ctx, pctx, l, tc.params) require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr) assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath) } } func TestPathRelativeFromInclude(t *testing.T) { t.Parallel() testCases := []struct { include map[string]config.IncludeConfig configPath string expectedPath string params []string }{ { configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: ".", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: "..", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: "..", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../../..", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../../..", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../../other-child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../..", }, { include: map[string]config.IncludeConfig{ "root": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}, "child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}, }, params: []string{"child"}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "../../other-child", }, } for _, tc := range testCases { trackInclude := getTrackIncludeFromTestData(tc.include, tc.params) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) pctx = pctx.WithTrackInclude(trackInclude) actualPath, actualErr := config.PathRelativeFromInclude(ctx, pctx, l, tc.params) require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr) assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath) } } func TestRunCommand(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { t.Skip("Skipping test on Windows because it doesn't support bash") } homeDir := os.Getenv("HOME") testCases := []struct { expectedErr error configPath string expectedOutput string params []string }{ { params: []string{"/bin/bash", "-c", "echo -n foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo -n foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-quiet", "--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-quiet", "--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedOutput: "foo", }, { params: []string{"--terragrunt-no-cache", "--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedErr: config.ConflictingRunCmdCacheOptionsError{}, }, { params: []string{"--terragrunt-global-cache", "--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, configPath: homeDir, expectedErr: config.ConflictingRunCmdCacheOptionsError{}, }, { configPath: homeDir, expectedErr: config.EmptyStringNotAllowedError("{run_cmd()}"), }, } for _, tc := range testCases { t.Run(tc.configPath, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) actualOutput, actualErr := config.RunCommand(ctx, pctx, l, tc.params) if tc.expectedErr != nil { if assert.Error(t, actualErr) { assertErrorType(t, tc.expectedErr, actualErr) } } else { require.NoError(t, actualErr) assert.Equal(t, tc.expectedOutput, actualOutput) } }) } } func absPath(t *testing.T, path string) string { t.Helper() if filepath.IsAbs(path) { return filepath.Clean(path) } absPath, err := filepath.Abs(path) require.NoError(t, err) return filepath.Clean(absPath) } func TestFindInParentFolders(t *testing.T) { t.Parallel() testCases := []struct { expectedErr error configPath string name string expectedPath string params []string maxFoldersToCheck int }{ { name: "simple-lookup", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"), }, { name: "nested-lookup", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"), }, { name: "lookup-with-max-folders", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "no-terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)), maxFoldersToCheck: 3, expectedErr: config.ParentFileNotFoundError{}, }, { name: "multiple-terragrunt-in-parents", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/root.hcl"), }, { name: "multiple-terragrunt-in-parents-under-child", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", "sub-child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/root.hcl"), }, { name: "multiple-terragrunt-in-parents-under-sub-child", params: []string{"root.hcl"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/root.hcl"), }, { name: "parent-file-that-isnt-terragrunt", params: []string{"foo.txt"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "other-file-names", "child", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/other-file-names/foo.txt"), }, { name: "parent-file-that-isnt-terragrunt-in-another-subfolder", params: []string{"common/foo.txt"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "in-another-subfolder", "live", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/in-another-subfolder/common/foo.txt"), }, { name: "parent-file-that-isnt-terragrunt-in-another-subfolder-with-params", params: []string{"tfwork"}, configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "with-params", "tfwork", "tg", config.DefaultTerragruntConfigPath)), expectedPath: absPath(t, "../../test/fixtures/parent-folders/with-params/tfwork"), }, { name: "not-found", configPath: "/", expectedErr: config.ParentFileNotFoundError{}, }, { name: "not-found-with-path", configPath: "/fake/path", expectedErr: config.ParentFileNotFoundError{}, }, { name: "fallback", params: []string{"foo.txt", "fallback.txt"}, configPath: "/fake/path", expectedPath: "fallback.txt", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) if tc.maxFoldersToCheck != 0 { pctx.MaxFoldersToCheck = tc.maxFoldersToCheck } actualPath, actualErr := config.FindInParentFolders(ctx, pctx, l, tc.params) if tc.expectedErr != nil { if assert.Error(t, actualErr) { assertErrorType(t, tc.expectedErr, actualErr) } } else { require.NoError(t, actualErr) assert.Equal(t, tc.expectedPath, actualPath) } }) } } func TestFindInParentFoldersWithStackFile(t *testing.T) { t.Parallel() tempDir := helpers.TmpDirWOSymlinks(t) regionHclPath := filepath.Join(tempDir, "region.hcl") regionHclContent := `locals { aws_region = "us-east-1" }` err := os.WriteFile(regionHclPath, []byte(regionHclContent), 0644) require.NoError(t, err) stackDir := filepath.Join(tempDir, "stack") err = os.MkdirAll(stackDir, 0755) require.NoError(t, err) stackHclPath := filepath.Join(stackDir, "terragrunt.stack.hcl") stackHclContent := `locals { regions_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) region = local.regions_vars.locals.aws_region } unit "test" { source = "." path = "test" }` err = os.WriteFile(stackHclPath, []byte(stackHclContent), 0644) require.NoError(t, err) l := logger.CreateLogger() _, pctx := newTestParsingContext(t, stackHclPath) pctx.WorkingDir = tempDir stackConfig, err := config.ReadStackConfigFile(t.Context(), l, pctx, stackHclPath, nil) require.NoError(t, err) require.NotNil(t, stackConfig) region, exists := stackConfig.Locals["region"] require.True(t, exists, "Expected 'region' local to be parsed") require.Equal(t, "us-east-1", region) } func TestResolveTerragruntInterpolation(t *testing.T) { t.Parallel() testCases := []struct { include *config.IncludeConfig str string configPath string expectedOut string expectedErr string maxFoldersToCheck int }{ { str: "terraform { source = path_relative_to_include() }", configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: ".", }, { str: "terraform { source = path_relative_to_include() }", include: &config.IncludeConfig{Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "child", }, { str: "terraform { source = find_in_parent_folders(\"root.hcl\") }", configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)), expectedOut: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"), }, { str: "terraform { source = find_in_parent_folders(\"root.hcl\") }", configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)), expectedErr: "ParentFileNotFoundError", maxFoldersToCheck: 1, }, { str: "terraform { source = find_in_parent_folders(\"root.hcl\") }", configPath: absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "no-terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)), expectedErr: "ParentFileNotFoundError", maxFoldersToCheck: 3, }, } for _, tc := range testCases { // The following is necessary to make sure tc's values don't // get updated due to concurrency within the scope of t.Run(..) below t.Run(fmt.Sprintf("%s--%s", tc.str, tc.configPath), func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) if tc.maxFoldersToCheck != 0 { pctx.MaxFoldersToCheck = tc.maxFoldersToCheck } actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include) if tc.expectedErr != "" { require.Error(t, actualErr) assert.Contains(t, actualErr.Error(), tc.expectedErr) } else { require.NoError(t, actualErr) assert.NotNil(t, actualOut) assert.NotNil(t, actualOut.Terraform) assert.NotNil(t, actualOut.Terraform.Source) assert.Equal(t, tc.expectedOut, *actualOut.Terraform.Source) } }) } } func TestResolveEnvInterpolationConfigString(t *testing.T) { t.Parallel() testCases := []struct { include *config.IncludeConfig env map[string]string str string configPath string expectedOut string expectedErr string }{ { str: `iam_role = "foo/${get_env()}/bar"`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedErr: "InvalidGetEnvParamsError", }, { str: `iam_role = "foo/${get_env("","")}/bar"`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedErr: "InvalidEnvParamNameError", }, { str: `iam_role = get_env()`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedErr: "InvalidGetEnvParamsError", }, { str: `iam_role = get_env("TEST_VAR_1", "TEST_VAR_2", "TEST_VAR_3")`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedErr: "InvalidGetEnvParamsError", }, { str: `iam_role = get_env("TEST_ENV_TERRAGRUNT_VAR")`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "SOMETHING", env: map[string]string{"TEST_ENV_TERRAGRUNT_VAR": "SOMETHING"}, }, { str: `iam_role = get_env("SOME_VAR", "SOME_VALUE")`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "SOME_VALUE", }, { str: `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_HIT","")}/bar"`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "foo//bar", env: map[string]string{"TEST_ENV_TERRAGRUNT_OTHER": "SOMETHING"}, }, { str: `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_HIT","DEFAULT")}/bar"`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "foo/DEFAULT/bar", env: map[string]string{"TEST_ENV_TERRAGRUNT_OTHER": "SOMETHING"}, }, { str: `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_VAR")}/bar"`, configPath: filepath.Join("/root", "child", config.DefaultTerragruntConfigPath), expectedOut: "foo/SOMETHING/bar", env: map[string]string{"TEST_ENV_TERRAGRUNT_VAR": "SOMETHING"}, }, } for _, tc := range testCases { // The following is necessary to make sure tc's values don't // get updated due to concurrency within the scope of t.Run(..) below t.Run(tc.str, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) if tc.env != nil { pctx.Env = tc.env } actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include) if tc.expectedErr != "" { require.Error(t, actualErr) assert.Contains(t, actualErr.Error(), tc.expectedErr) } else { require.NoError(t, actualErr) assert.Equal(t, tc.expectedOut, actualOut.IamRole) } }) } } func TestResolveCommandsInterpolationConfigString(t *testing.T) { t.Parallel() testCases := []struct { include *config.IncludeConfig str string configPath string expectedFooInput []string }{ { str: "inputs = { foo = get_terraform_commands_that_need_locking() }", configPath: config.DefaultTerragruntConfigPath, expectedFooInput: config.TerraformCommandsNeedLocking, }, { str: `inputs = { foo = get_terraform_commands_that_need_vars() }`, configPath: config.DefaultTerragruntConfigPath, expectedFooInput: config.TerraformCommandsNeedVars, }, { str: "inputs = { foo = get_terraform_commands_that_need_parallelism() }", configPath: config.DefaultTerragruntConfigPath, expectedFooInput: config.TerraformCommandsNeedParallelism, }, } for _, tc := range testCases { // The following is necessary to make sure tc's values don't // get updated due to concurrency within the scope of t.Run(..) below t.Run(tc.str, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include) require.NoError(t, actualErr, "For string '%s' include %v and configPath %v, unexpected error: %v", tc.str, tc.include, tc.configPath, actualErr) assert.NotNil(t, actualOut) inputs := actualOut.Inputs assert.NotNil(t, inputs) foo, containsFoo := inputs["foo"] assert.True(t, containsFoo) fooSlice := toStringSlice(t, foo) assert.Equal(t, tc.expectedFooInput, fooSlice, "For string '%s' include %v and configPath %v", tc.str, tc.include, tc.configPath) }) } } func TestResolveCliArgsInterpolationConfigString(t *testing.T) { t.Parallel() for _, cliArgs := range [][]string{nil, {}, {"apply"}, {"plan", "-out=planfile"}} { expectedFooInput := cliArgs // Expecting nil to be returned for get_terraform_cli_args() call for // either nil or empty array of input args if len(cliArgs) == 0 { expectedFooInput = nil } str := "inputs = { foo = get_terraform_cli_args() }" t.Run(str, func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx.TerraformCliArgs = iacargs.New(cliArgs...) actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", str, nil) require.NoError(t, actualErr, "For string '%s', unexpected error: %v", str, actualErr) assert.NotNil(t, actualOut) inputs := actualOut.Inputs assert.NotNil(t, inputs) foo, containsFoo := inputs["foo"] assert.True(t, containsFoo) fooSlice := toStringSlice(t, foo) assert.Equal(t, expectedFooInput, fooSlice, "For string '%s'", str) }) } } func toStringSlice(t *testing.T, value any) []string { t.Helper() if value == nil { return nil } asInterfaceSlice, isInterfaceSlice := value.([]any) assert.True(t, isInterfaceSlice) // TODO: See if this logic is desired if len(asInterfaceSlice) == 0 { return nil } var out = make([]string, 0, len(asInterfaceSlice)) for _, item := range asInterfaceSlice { asStr, isStr := item.(string) assert.True(t, isStr) out = append(out, asStr) } return out } func TestGetTerragruntDirAbsPath(t *testing.T) { t.Parallel() workingDir, err := os.Getwd() require.NoError(t, err, "Could not get current working dir: %v", err) testGetTerragruntDir(t, "/foo/bar/terragrunt.hcl", filepath.VolumeName(workingDir)+"/foo/bar") } func TestGetTerragruntDirRelPath(t *testing.T) { t.Parallel() testGetTerragruntDir(t, "foo/bar/terragrunt.hcl", filepath.Join("foo", "bar")) } func testGetTerragruntDir(t *testing.T, configPath string, expectedPath string) { t.Helper() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, configPath) actualPath, err := config.GetTerragruntDir(ctx, pctx, l) require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, expectedPath, actualPath) } // newTestParsingContext creates a ParsingContext with sensible test defaults. // Replicates NewTerragruntOptionsForTest + configbridge.populateFromOpts. func newTestParsingContext(tb testing.TB, configPath string) (context.Context, *config.ParsingContext) { tb.Helper() l := logger.CreateLogger() ctx, pctx := config.NewParsingContext(tb.Context(), l, config.WithStrictControls(controls.New())) workingDir, downloadDir := util.DefaultWorkingAndDownloadDirs(configPath) pctx.TerragruntConfigPath = configPath pctx.WorkingDir = workingDir pctx.RootWorkingDir = workingDir pctx.DownloadDir = downloadDir pctx.TFPath = "tofu" pctx.AutoInit = true pctx.Env = map[string]string{} pctx.SourceMap = map[string]string{} pctx.TerraformCliArgs = iacargs.New() pctx.Writers.Writer = os.Stdout pctx.Writers.ErrWriter = os.Stderr pctx.MaxFoldersToCheck = 100 pctx.TofuImplementation = tfimpl.Unknown pctx.Experiments = experiment.NewExperiments() pctx.Telemetry = new(telemetry.Options) pctx.EngineOptions = new(engine.EngineOptions) pctx.FeatureFlags = xsync.NewMapOf[string, string]() return ctx, pctx } func TestGetParentTerragruntDir(t *testing.T) { t.Parallel() currentDir, err := os.Getwd() require.NoError(t, err, "Could not get current working dir: %v", err) parentDir := filepath.Dir(currentDir) testCases := []struct { include map[string]config.IncludeConfig configPath string expectedPath string params []string }{ { configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: filepath.Join(helpers.RootFolder, "child"), }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: helpers.RootFolder, }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath), expectedPath: helpers.RootFolder, }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: helpers.RootFolder, }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath), expectedPath: helpers.RootFolder, }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: filepath.VolumeName(parentDir) + "/other-child", }, { include: map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}}, configPath: filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: "..", }, { include: map[string]config.IncludeConfig{ "root": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}, "child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}, }, params: []string{"child"}, configPath: filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath), expectedPath: filepath.VolumeName(parentDir) + "/other-child", }, } for _, tc := range testCases { trackInclude := getTrackIncludeFromTestData(tc.include, tc.params) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, tc.configPath) pctx = pctx.WithTrackInclude(trackInclude) actualPath, actualErr := config.GetParentTerragruntDir(ctx, pctx, l, tc.params) require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr) assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath) } } func TestTerraformBuiltInFunctions(t *testing.T) { t.Parallel() testCases := []struct { expected any input string }{ { input: "abs(-1)", expected: 1., }, { input: `element(["one", "two", "three"], 1)`, expected: "two", }, { input: `chomp(file("other-file.txt"))`, expected: "This is a test file", }, { input: `sha1("input")`, expected: "140f86aae51ab9e1cda9b4254fe98a74eb54c1a1", }, { input: `split("|", "one|two|three")`, expected: []any{"one", "two", "three"}, }, { input: `!tobool("false")`, expected: true, }, { input: `trimspace(" content ")`, expected: "content", }, { input: `zipmap(["one", "two", "three"], [1, 2, 3])`, expected: map[string]any{"one": 1., "two": 2., "three": 3.}, }, } for _, tc := range testCases { t.Run(tc.input, func(t *testing.T) { t.Parallel() cfgPath := filepath.Join("../..", "test", "fixtures", "config-terraform-functions", config.DefaultTerragruntConfigPath) configString := fmt.Sprintf("inputs = { test = %s }", tc.input) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, cfgPath) actual, err := config.ParseConfigString(ctx, pctx, l, cfgPath, configString, nil) require.NoError(t, err, "For hcl '%s', unexpected error: %v", tc.input, err) assert.NotNil(t, actual) inputs := actual.Inputs assert.NotNil(t, inputs) test, containsTest := inputs["test"] assert.True(t, containsTest) assert.Equal(t, tc.expected, test, "For hcl '%s'", tc.input) }) } } func TestTerraformOutputJsonToCtyValueMap(t *testing.T) { t.Parallel() testCases := []struct { expected map[string]cty.Value input string }{ { input: `{"bool": {"sensitive": false, "type": "bool", "value": true}}`, expected: map[string]cty.Value{"bool": cty.True}, }, { input: `{"number": {"sensitive": false, "type": "number", "value": 42}}`, expected: map[string]cty.Value{"number": cty.NumberIntVal(42)}, }, { input: `{"list_string": {"sensitive": false, "type": ["list", "string"], "value": ["4", "2"]}}`, expected: map[string]cty.Value{"list_string": cty.ListVal([]cty.Value{cty.StringVal("4"), cty.StringVal("2")})}, }, { input: `{"map_string": {"sensitive": false, "type": ["map", "string"], "value": {"x": "foo", "y": "bar"}}}`, expected: map[string]cty.Value{"map_string": cty.MapVal(map[string]cty.Value{"x": cty.StringVal("foo"), "y": cty.StringVal("bar")})}, }, { input: `{"map_list_number": {"sensitive": false, "type": ["map", ["list", "number"]], "value": {"x": [4, 2]}}}`, expected: map[string]cty.Value{ "map_list_number": cty.MapVal( map[string]cty.Value{ "x": cty.ListVal([]cty.Value{cty.NumberIntVal(4), cty.NumberIntVal(2)}), }, ), }, }, { input: `{"object": {"sensitive": false, "type": ["object", {"x": "number", "y": "string", "lst": ["list", "string"]}], "value": {"x": 42, "y": "the truth", "lst": ["foo", "bar"]}}}`, expected: map[string]cty.Value{ "object": cty.ObjectVal( map[string]cty.Value{ "x": cty.NumberIntVal(42), "y": cty.StringVal("the truth"), "lst": cty.ListVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), }, ), }, }, { input: `{"out1": {"sensitive": false, "type": "number", "value": 42}, "out2": {"sensitive": false, "type": "string", "value": "foo bar"}}`, expected: map[string]cty.Value{ "out1": cty.NumberIntVal(42), "out2": cty.StringVal("foo bar"), }, }, } mockTargetConfig := config.DefaultTerragruntConfigPath for _, tc := range testCases { converted, err := config.TerraformOutputJSONToCtyValueMap(mockTargetConfig, []byte(tc.input)) require.NoError(t, err) assert.Equal(t, getKeys(converted), getKeys(tc.expected)) for k, v := range converted { assert.True(t, v.Equals(tc.expected[k]).True()) } } } func TestReadTerragruntConfigInputs(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/inputs/terragrunt.hcl", nil) require.NoError(t, err) tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) inputsMap := tgConfigMap["inputs"].(map[string]any) assert.Equal(t, "string", inputsMap["string"].(string)) assert.InEpsilon(t, float64(42), inputsMap["number"].(float64), 0.0000000001) assert.True(t, inputsMap["bool"].(bool)) assert.Equal(t, []any{"a", "b", "c"}, inputsMap["list_string"].([]any)) assert.Equal(t, []any{float64(1), float64(2), float64(3)}, inputsMap["list_number"].([]any)) assert.Equal(t, []any{true, false}, inputsMap["list_bool"].([]any)) assert.Equal(t, map[string]any{"foo": "bar"}, inputsMap["map_string"].(map[string]any)) assert.Equal(t, map[string]any{"foo": float64(42), "bar": float64(12345)}, inputsMap["map_number"].(map[string]any)) assert.Equal(t, map[string]any{"foo": true, "bar": false, "baz": true}, inputsMap["map_bool"].(map[string]any)) assert.Equal( t, map[string]any{ "str": "string", "num": float64(42), "list": []any{float64(1), float64(2), float64(3)}, "map": map[string]any{"foo": "bar"}, }, inputsMap["object"].(map[string]any), ) assert.Equal(t, "default", inputsMap["from_env"].(string)) } func TestReadTerragruntConfigRemoteState(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/terragrunt/terragrunt.hcl", nil) require.NoError(t, err) tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) remoteStateMap := tgConfigMap["remote_state"].(map[string]any) assert.Equal(t, "s3", remoteStateMap["backend"].(string)) configMap := remoteStateMap["config"].(map[string]any) assert.True(t, configMap["encrypt"].(bool)) assert.Equal(t, "terraform.tfstate", configMap["key"].(string)) assert.Equal( t, map[string]any{"owner": "terragrunt integration test", "name": "Terraform state storage"}, configMap["s3_bucket_tags"].(map[string]any), ) assert.Equal( t, map[string]any{"owner": "terragrunt integration test", "name": "Terraform lock table"}, configMap["dynamodb_table_tags"].(map[string]any), ) assert.Equal( t, map[string]any{"owner": "terragrunt integration test", "name": "Terraform access log storage"}, configMap["accesslogging_bucket_tags"].(map[string]any), ) } func TestReadTerragruntConfigHooks(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/hooks/before-after-and-on-error/terragrunt.hcl", nil) require.NoError(t, err) tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) terraformMap := tgConfigMap["terraform"].(map[string]any) beforeHooksMap := terraformMap["before_hook"].(map[string]any) assert.Equal( t, []any{"touch", "before.out"}, beforeHooksMap["before_hook_1"].(map[string]any)["execute"].([]any), ) assert.Equal( t, []any{"echo", "BEFORE_TERRAGRUNT_READ_CONFIG"}, beforeHooksMap["before_hook_2"].(map[string]any)["execute"].([]any), ) afterHooksMap := terraformMap["after_hook"].(map[string]any) assert.Equal( t, []any{"touch", "after.out"}, afterHooksMap["after_hook_1"].(map[string]any)["execute"].([]any), ) assert.Equal( t, []any{"echo", "AFTER_TERRAGRUNT_READ_CONFIG"}, afterHooksMap["after_hook_2"].(map[string]any)["execute"].([]any), ) errorHooksMap := terraformMap["error_hook"].(map[string]any) assert.Equal( t, []any{"echo", "ON_APPLY_ERROR"}, errorHooksMap["error_hook_1"].(map[string]any)["execute"].([]any), ) } func TestReadTerragruntConfigLocals(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/locals/canonical/terragrunt.hcl", nil) require.NoError(t, err) tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) localsMap := tgConfigMap["locals"].(map[string]any) assert.InEpsilon(t, float64(2), localsMap["x"].(float64), 0.0000000001) assert.Equal(t, "Hello world", strings.TrimSpace(localsMap["file_contents"].(string))) assert.InEpsilon(t, float64(42), localsMap["number_expression"].(float64), 0.0000000001) } func TestGetTerragruntSourceForModuleHappyPath(t *testing.T) { t.Parallel() testCases := []struct { config *config.TerragruntConfig source string expected string }{ {config: mockConfigWithSource(""), source: "", expected: ""}, {config: mockConfigWithSource(""), source: "/source/modules", expected: ""}, {config: mockConfigWithSource("git::git@github.com:acme/modules.git//foo/bar"), source: "/source/modules", expected: "/source/modules//foo/bar"}, {config: mockConfigWithSource("git::git@github.com:acme/modules.git//foo/bar?ref=v0.0.1"), source: "/source/modules", expected: "/source/modules//foo/bar"}, {config: mockConfigWithSource("git::git@github.com:acme/emr_cluster.git?ref=feature/fix_bugs"), source: "/source/modules", expected: "/source/modules//emr_cluster"}, {config: mockConfigWithSource("git::ssh://git@ghe.ourcorp.com/OurOrg/some-module.git"), source: "/source/modules", expected: "/source/modules//some-module"}, {config: mockConfigWithSource("github.com/hashicorp/example"), source: "/source/modules", expected: "/source/modules//example"}, {config: mockConfigWithSource("github.com/hashicorp/example//subdir"), source: "/source/modules", expected: "/source/modules//subdir"}, {config: mockConfigWithSource("git@github.com:hashicorp/example.git//subdir"), source: "/source/modules", expected: "/source/modules//subdir"}, {config: mockConfigWithSource("./some/path//to/modulename"), source: "/source/modules", expected: "/source/modules//to/modulename"}, } for _, tc := range testCases { t.Run(fmt.Sprintf("%v-%s", *tc.config.Terraform.Source, tc.source), func(t *testing.T) { t.Parallel() actual, err := config.GetTerragruntSourceForModule(tc.source, "mock-for-test", tc.config) require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } } func TestStartsWith(t *testing.T) { t.Parallel() testCases := []struct { args []string expected bool }{ {args: []string{"hello world", "hello"}, expected: true}, {args: []string{"hello world", "world"}, expected: false}, {args: []string{"hello world", ""}, expected: true}, {args: []string{"hello world", " "}, expected: false}, {args: []string{"", ""}, expected: true}, {args: []string{"", " "}, expected: false}, {args: []string{" ", ""}, expected: true}, {args: []string{"", "hello"}, expected: false}, {args: []string{" ", "hello"}, expected: false}, } for id, tc := range testCases { t.Run(fmt.Sprintf("%v %v", id, tc.args), func(t *testing.T) { t.Parallel() ctx, pctx := newTestParsingContext(t, "") actual, err := config.StartsWith(ctx, pctx, tc.args) require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } } func TestEndsWith(t *testing.T) { t.Parallel() testCases := []struct { args []string expected bool }{ {args: []string{"hello world", "world"}, expected: true}, {args: []string{"hello world", "hello"}, expected: false}, {args: []string{"hello world", ""}, expected: true}, {args: []string{"hello world", " "}, expected: false}, {args: []string{"", ""}, expected: true}, {args: []string{"", " "}, expected: false}, {args: []string{" ", ""}, expected: true}, {args: []string{"", "hello"}, expected: false}, {args: []string{" ", "hello"}, expected: false}, } for id, tc := range testCases { t.Run(fmt.Sprintf("%v %v", id, tc.args), func(t *testing.T) { t.Parallel() ctx, pctx := newTestParsingContext(t, "") actual, err := config.EndsWith(ctx, pctx, tc.args) require.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } } func TestTimeCmp(t *testing.T) { t.Parallel() testCases := []struct { err string args []string value int64 }{ { args: []string{"2017-11-22T00:00:00Z", "2017-11-22T00:00:00Z"}, }, { args: []string{"2017-11-22T00:00:00Z", "2017-11-22T01:00:00+01:00"}, }, { args: []string{"2017-11-22T00:00:01Z", "2017-11-22T01:00:00+01:00"}, value: 1, }, { args: []string{"2017-11-22T01:00:00Z", "2017-11-22T00:59:00-01:00"}, value: -1, }, { args: []string{"2017-11-22T01:00:00+01:00", "2017-11-22T01:00:00-01:00"}, value: -1, }, { args: []string{"2017-11-22T01:00:00-01:00", "2017-11-22T01:00:00+01:00"}, value: 1, }, { args: []string{"2017-11-22T00:00:00Z", "bloop"}, err: `could not parse second parameter "bloop": not a valid RFC3339 timestamp: cannot use "bloop" as year`, }, { args: []string{"2017-11-22 00:00:00Z", "2017-11-22T00:00:00Z"}, err: `could not parse first parameter "2017-11-22 00:00:00Z": not a valid RFC3339 timestamp: missing required time introducer 'T'`, }, } for _, tc := range testCases { t.Run(fmt.Sprintf("TimeCmp(%#v, %#v)", tc.args[0], tc.args[1]), func(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, "") actual, err := config.TimeCmp(ctx, pctx, l, tc.args) if tc.err != "" { require.EqualError(t, err, tc.err) } else { require.NoError(t, err) } assert.Equal(t, tc.value, actual) }) } } func TestStrContains(t *testing.T) { t.Parallel() testCases := []struct { err string args []string value bool }{ { args: []string{"hello world", "hello"}, value: true, }, { args: []string{"hello world", "world"}, value: true, }, { args: []string{"hello world0", "0"}, value: true, }, { args: []string{"hello world", "test"}, }, } for _, tc := range testCases { t.Run(fmt.Sprintf("StrContains %v", tc.args), func(t *testing.T) { t.Parallel() ctx, pctx := newTestParsingContext(t, "") actual, err := config.StrContains(ctx, pctx, tc.args) if tc.err != "" { require.EqualError(t, err, tc.err) } else { require.NoError(t, err) } assert.Equal(t, tc.value, actual) }) } } func TestReadTFVarsFiles(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/read-tf-vars/terragrunt.hcl", nil) require.NoError(t, err) tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty) require.NoError(t, err) locals := tgConfigMap["locals"].(map[string]any) assert.Equal(t, "string", locals["string_var"].(string)) assert.InEpsilon(t, float64(42), locals["number_var"].(float64), 0.0000000001) assert.True(t, locals["bool_var"].(bool)) assert.Equal(t, []any{"hello", "world"}, locals["list_var"].([]any)) assert.InEpsilon(t, float64(24), locals["json_number_var"].(float64), 0.0000000001) assert.Equal(t, "another string", locals["json_string_var"].(string)) assert.False(t, locals["json_bool_var"].(bool)) } func mockConfigWithSource(sourceURL string) *config.TerragruntConfig { cfg := config.TerragruntConfig{IsPartial: true} cfg.Terraform = &config.TerraformConfig{Source: &sourceURL} return &cfg } // Return keys as a map so it is treated like a set, and order doesn't matter when comparing equivalence func getKeys(valueMap map[string]cty.Value) map[string]bool { keys := map[string]bool{} for k := range valueMap { keys[k] = true } return keys } func getTrackIncludeFromTestData(includeMap map[string]config.IncludeConfig, params []string) *config.TrackInclude { if len(includeMap) == 0 { return nil } currentList := make([]config.IncludeConfig, len(includeMap)) i := 0 for _, val := range includeMap { currentList[i] = val i++ } trackInclude := &config.TrackInclude{ CurrentList: currentList, CurrentMap: includeMap, } if len(params) == 0 { trackInclude.Original = ¤tList[0] } return trackInclude } func TestConstraintCheck(t *testing.T) { t.Parallel() testCases := []struct { err string args []string value bool }{ { args: []string{"1.2", ">= 1.0, < 1.4"}, value: true, }, { args: []string{"1.0", ">= 1.0, < 1.4"}, value: true, }, { args: []string{"1.4", ">= 1.0, < 1.4"}, value: false, }, { args: []string{"1.E", ">= 1.0, < 1.4"}, value: false, err: "invalid version 1.E: malformed version: 1.E", }, { args: []string{"1.4", ">== 1.0, < 1.4"}, value: false, err: "invalid constraint >== 1.0, < 1.4: malformed constraint: >== 1.0", }, } for _, tc := range testCases { t.Run(fmt.Sprintf("constraint_check(%#v, %#v)", tc.args[0], tc.args[1]), func(t *testing.T) { t.Parallel() ctx, pctx := newTestParsingContext(t, "") actual, err := config.ConstraintCheck(ctx, pctx, tc.args) if tc.err != "" { require.EqualError(t, err, tc.err) } else { require.NoError(t, err) } assert.Equal(t, tc.value, actual) }) } } ================================================ FILE: pkg/config/config_partial.go ================================================ package config import ( "context" "fmt" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/huandu/go-clone" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) // PartialDecodeSectionType is an enum that is used to list out which blocks/sections of the terragrunt config should be // decoded in a partial decode. type PartialDecodeSectionType int const ( DependenciesBlock PartialDecodeSectionType = iota DependencyBlock TerraformBlock TerraformSource TerragruntFlags TerragruntVersionConstraints RemoteStateBlock FeatureFlagsBlock EngineBlock ExcludeBlock ErrorsBlock ) // terragruntIncludeMultiple is a struct that can be used to only decode the include block with labels. type terragruntIncludeMultiple struct { Remain hcl.Body `hcl:",remain"` Include IncludeConfigs `hcl:"include,block"` } // terragruntDependencies is a struct that can be used to only decode the dependencies block. type terragruntDependencies struct { Dependencies *ModuleDependencies `hcl:"dependencies,block"` Remain hcl.Body `hcl:",remain"` } // terragruntFeatureFlags is a struct that can be used to store decoded feature flags. type terragruntFeatureFlags struct { Remain hcl.Body `hcl:",remain"` FeatureFlags FeatureFlags `hcl:"feature,block"` } // terragruntErrors struct to decode errors block type terragruntErrors struct { Errors *ErrorsConfig `hcl:"errors,block"` Remain hcl.Body `hcl:",remain"` } // terragruntTerraform is a struct that can be used to only decode the terraform block. type terragruntTerraform struct { Terraform *TerraformConfig `hcl:"terraform,block"` Remain hcl.Body `hcl:",remain"` } // terragruntTerraformSource is a struct that can be used to only decode the terraform block, and only the source // attribute. type terragruntTerraformSource struct { Terraform *terraformConfigSourceOnly `hcl:"terraform,block"` Remain hcl.Body `hcl:",remain"` } // terraformConfigSourceOnly is a struct that can be used to decode only the source attribute of the terraform block. type terraformConfigSourceOnly struct { Source *string `hcl:"source,attr"` Remain hcl.Body `hcl:",remain"` } // terragruntFlags is a struct that can be used to only decode the flag attributes (prevent_destroy) type terragruntFlags struct { IamRole *string `hcl:"iam_role,attr"` IamWebIdentityToken *string `hcl:"iam_web_identity_token,attr"` PreventDestroy *bool `hcl:"prevent_destroy,attr"` Remain hcl.Body `hcl:",remain"` } // terragruntVersionConstraints is a struct that can be used to only decode the attributes related to constraining the // versions of terragrunt and terraform. type terragruntVersionConstraints struct { TerragruntVersionConstraint *string `hcl:"terragrunt_version_constraint,attr"` TerraformVersionConstraint *string `hcl:"terraform_version_constraint,attr"` TerraformBinary *string `hcl:"terraform_binary,attr"` Remain hcl.Body `hcl:",remain"` } // TerragruntDependency is a struct that can be used to only decode the dependency blocks in the terragrunt config type TerragruntDependency struct { Remain hcl.Body `hcl:",remain"` Dependencies Dependencies `hcl:"dependency,block"` } // terragruntRemoteState is a struct that can be used to only decode the remote_state blocks in the terragrunt config type terragruntRemoteState struct { RemoteState *remotestate.ConfigFile `hcl:"remote_state,block"` Remain hcl.Body `hcl:",remain"` } // terragruntEngine is a struct that can only be used to decode the engine block. type terragruntEngine struct { Engine *EngineConfig `hcl:"engine,block"` Remain hcl.Body `hcl:",remain"` } // DecodeBaseBlocks takes in a parsed HCL2 file and decodes the base blocks. Base blocks are blocks that should always // be decoded even in partial decoding, because they provide bindings that are necessary for parsing any block in the // file. Currently base blocks are: // - locals // - features // - include func DecodeBaseBlocks(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig) (*DecodedBaseBlocks, error) { errs := &errors.MultiError{} evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } // Decode just the `include` and `import` blocks, and verify that it's allowed here terragruntIncludeList, err := decodeAsTerragruntInclude( file, evalParsingContext, ) if err != nil { errs = errs.Append(err) } trackInclude, err := getTrackInclude(pctx, terragruntIncludeList, includeFromChild) if err != nil { errs = errs.Append(err) } // set feature flags tgFlags := terragruntFeatureFlags{} // load default feature flags if err := file.Decode(&tgFlags, evalParsingContext); err != nil { return nil, err } // validate flags to have default value, collect errors flagErrs := &errors.MultiError{} for _, flag := range tgFlags.FeatureFlags { if flag.Default == nil { flagErr := fmt.Errorf("feature flag %s does not have a default value in %s", flag.Name, file.ConfigPath) flagErrs = flagErrs.Append(flagErr) } } if flagErrs.ErrorOrNil() != nil { errs = errs.Append(flagErrs) } flagsAsCtyVal, err := flagsAsCty(pctx, tgFlags.FeatureFlags) if err != nil { errs = errs.Append(err) } // Evaluate all the expressions in the locals block separately and generate the variables list to use in the // evaluation ctx. locals, err := EvaluateLocalsBlock(ctx, pctx.WithTrackInclude(trackInclude).WithFeatures(&flagsAsCtyVal), l, file) if err != nil { errs = errs.Append(err) } localsAsCtyVal, err := ConvertValuesMapToCtyVal(locals) if err != nil { return nil, err } return &DecodedBaseBlocks{ TrackInclude: trackInclude, Locals: &localsAsCtyVal, FeatureFlags: &flagsAsCtyVal, }, errs.ErrorOrNil() } func flagsAsCty(ctx *ParsingContext, tgFlags FeatureFlags) (cty.Value, error) { // extract all flags in map by name flagByName := map[string]*FeatureFlag{} for _, flag := range tgFlags { flagByName[flag.Name] = flag } evaluatedFlags, err := cliFlagsToCty(ctx, flagByName) if err != nil { return cty.NilVal, err } errs := &errors.MultiError{} for _, flag := range tgFlags { if _, exists := evaluatedFlags[flag.Name]; !exists { if flag.Default == nil { errs = errs.Append(fmt.Errorf("feature flag %s does not have a default value in %s", flag.Name, ctx.TerragruntConfigPath)) continue } contextFlag, err := flagToCtyValue(flag.Name, *flag.Default) if err != nil { return cty.NilVal, err } evaluatedFlags[flag.Name] = contextFlag } } flagsAsCtyVal, err := ConvertValuesMapToCtyVal(evaluatedFlags) if err != nil { return cty.NilVal, err } return flagsAsCtyVal, errs.ErrorOrNil() } // cliFlagsToCty converts CLI feature flags to Cty values. It returns a map of flag names // to their corresponding Cty values and any error encountered during conversion. func cliFlagsToCty(ctx *ParsingContext, flagByName map[string]*FeatureFlag) (map[string]cty.Value, error) { if ctx.FeatureFlags == nil { return make(map[string]cty.Value), nil } evaluatedFlags := make(map[string]cty.Value) var conversionErr error ctx.FeatureFlags.Range(func(name, value string) bool { var flag cty.Value var err error if existingFlag, ok := flagByName[name]; ok { flag, err = flagToTypedCtyValue(name, existingFlag.Default.Type(), value) } else { flag, err = flagToCtyValue(name, value) } if err != nil { conversionErr = err return false } evaluatedFlags[name] = flag return true }) if conversionErr != nil { return nil, conversionErr } return evaluatedFlags, nil } func PartialParseConfigFile(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string, include *IncludeConfig) (*TerragruntConfig, error) { hclCache := cache.ContextCache[*hclparse.File](ctx, HclCacheContextKey) fileInfo, err := os.Stat(configPath) if err != nil { if os.IsNotExist(err) { return nil, TerragruntConfigNotFoundError{Path: configPath} } return nil, errors.New(err) } cacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) // Check cache hit status before tracing _, cacheHit := hclCache.Get(ctx, cacheKey) var config *TerragruntConfig err = TraceParseConfigFile( ctx, configPath, pctx.WorkingDir, true, // isPartial pctx.PartialParseDecodeList, include, cacheHit, func(ctx context.Context) error { var file *hclparse.File if cacheConfig, found := hclCache.Get(ctx, cacheKey); found { file = cacheConfig } else { var parseErr error file, parseErr = hclparse.NewParser(pctx.ParserOptions...).ParseFromFile(configPath) if parseErr != nil { return parseErr } hclCache.Put(ctx, cacheKey, file) } var parseErr error config, parseErr = TerragruntConfigFromPartialConfig(ctx, pctx, l, file, include) return parseErr }) return config, err } // TerragruntConfigFromPartialConfig is a wrapper of PartialParseConfigString which checks for cached configs. // filename, configString, includeFromChild and decodeList are used for the cache key, // by getting the default value (%#v) through fmt. func TerragruntConfigFromPartialConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig) (*TerragruntConfig, error) { var cacheKey = fmt.Sprintf("%#v-%#v-%#v-%#v-%#v", file.ConfigPath, file.Content(), includeFromChild, pctx.PartialParseDecodeList, pctx.TerragruntConfigPath) terragruntConfigCache := cache.ContextCache[*TerragruntConfig](ctx, TerragruntConfigCacheContextKey) if pctx.UsePartialParseConfigCache { if config, found := terragruntConfigCache.Get(ctx, cacheKey); found { l.Debugf("Cache hit for '%s' (partial parsing), decodeList: '%v'.", pctx.TerragruntConfigPath, pctx.PartialParseDecodeList) deepCopy := clone.Clone(config).(*TerragruntConfig) return deepCopy, nil } l.Debugf("Cache miss for '%s' (partial parsing), decodeList: '%v'.", pctx.TerragruntConfigPath, pctx.PartialParseDecodeList) } config, err := PartialParseConfig(ctx, pctx, l, file, includeFromChild) if err != nil { return config, err } if pctx.UsePartialParseConfigCache { putConfig := clone.Clone(config).(*TerragruntConfig) terragruntConfigCache.Put(ctx, cacheKey, putConfig) } return config, nil } // PartialParseConfigString partially parses and decodes the provided string. Which blocks/attributes to decode is // controlled by the function parameter decodeList. These blocks/attributes are parsed and set on the output // TerragruntConfig. Valid values are: // - DependenciesBlock: Parses the `dependencies` block in the config // - DependencyBlock: Parses the `dependency` block in the config // - TerraformBlock: Parses the `terraform` block in the config // - TerragruntFlags: Parses the boolean flags `prevent_destroy` and `skip` in the config // - TerragruntVersionConstraints: Parses the attributes related to constraining terragrunt and terraform versions in // the config. // - RemoteStateBlock: Parses the `remote_state` block in the config // - FeatureFlagsBlock: Parses the `feature` block in the config // - EngineBlock: Parses the `engine` block in the config // - ExcludeBlock : Parses the `exclude` block in the config // // Note that the following blocks are always decoded: // - locals // - include // Note also that the following blocks are never decoded in a partial parse: // - inputs func PartialParseConfigString(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath, configString string, include *IncludeConfig) (*TerragruntConfig, error) { file, err := hclparse.NewParser(pctx.ParserOptions...).ParseFromString(configString, configPath) if err != nil { return nil, err } return PartialParseConfig(ctx, pctx, l, file, include) } func PartialParseConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig) (*TerragruntConfig, error) { errs := &errors.MultiError{} // Detect and block deprecated configurations early, before attempting to parse. // This ensures included configs with deprecated syntax get clear error messages // instead of cryptic "Could not find Terragrunt configuration settings" errors. // See: https://github.com/gruntwork-io/terragrunt/issues/4983 if err := DetectDeprecatedConfigurations(ctx, pctx, l, file); err != nil { return nil, err } pctx = pctx.WithTrackInclude(nil) // read unit files and add to context unitValues, err := ReadValues(ctx, pctx, l, filepath.Dir(file.ConfigPath)) if err != nil { return nil, err } pctx = pctx.WithValues(unitValues) // Decode just the Base blocks. See the function docs for DecodeBaseBlocks for more info on what base blocks are. // Initialize evaluation ctx extensions from base blocks. baseBlocks, err := DecodeBaseBlocks(ctx, pctx, l, file, includeFromChild) if err != nil { errs = errs.Append(err) } if baseBlocks != nil { pctx = pctx.WithTrackInclude(baseBlocks.TrackInclude) pctx = pctx.WithFeatures(baseBlocks.FeatureFlags) pctx = pctx.WithLocals(baseBlocks.Locals) } // Set parsed Locals on the parsed config output, err := convertToTerragruntConfig(ctx, pctx, file.ConfigPath, &terragruntConfigFile{}) if err != nil { return nil, err } output.IsPartial = true evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } // Now loop through each requested block / component to decode from the terragrunt config, decode them, and merge // them into the output TerragruntConfig struct. hasExcludeBlock := false for _, decode := range pctx.PartialParseDecodeList { switch decode { case DependenciesBlock: decoded := terragruntDependencies{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } // If we already decoded some dependencies, merge them in. Otherwise, set as the new list. if output.Dependencies != nil { output.Dependencies.Merge(decoded.Dependencies) } else { output.Dependencies = decoded.Dependencies } case TerraformBlock: decoded := terragruntTerraform{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } output.Terraform = decoded.Terraform case TerraformSource: decoded := terragruntTerraformSource{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if decoded.Terraform != nil { output.Terraform = &TerraformConfig{Source: decoded.Terraform.Source} } case DependencyBlock: decoded := TerragruntDependency{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } // In normal operation, if a dependency block does not have a `config_path` attribute, decoding returns an error since this attribute is required, but the `hclvalidate` command suppresses decoding errors and this causes a cycle between modules, so we need to filter out dependencies without a defined `config_path`. decoded.Dependencies = decoded.Dependencies.FilteredWithoutConfigPath() output.TerragruntDependencies = decoded.Dependencies // Convert dependency blocks into module dependency lists. If we already decoded some dependencies, // merge them in. Otherwise, set as the new list. dependencies := dependencyBlocksToModuleDependencies(l, decoded.Dependencies) if output.Dependencies != nil { output.Dependencies.Merge(dependencies) } else { output.Dependencies = dependencies } case EngineBlock: decoded := terragruntEngine{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } output.Engine = decoded.Engine case TerragruntFlags: decoded := terragruntFlags{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if decoded.PreventDestroy != nil { output.PreventDestroy = decoded.PreventDestroy } if decoded.IamRole != nil { output.IamRole = *decoded.IamRole } if decoded.IamWebIdentityToken != nil { output.IamWebIdentityToken = *decoded.IamWebIdentityToken } case TerragruntVersionConstraints: decoded := terragruntVersionConstraints{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if decoded.TerragruntVersionConstraint != nil { output.TerragruntVersionConstraint = *decoded.TerragruntVersionConstraint } if decoded.TerraformVersionConstraint != nil { output.TerraformVersionConstraint = *decoded.TerraformVersionConstraint } // If the TFPath is not explicitly set, use the TFPath from the config if it is set. if !pctx.TFPathExplicitlySet && decoded.TerraformBinary != nil { output.TerraformBinary = *decoded.TerraformBinary } case RemoteStateBlock: decoded := terragruntRemoteState{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if decoded.RemoteState != nil { config, err := decoded.RemoteState.Config() if err != nil { return nil, err } output.RemoteState = remotestate.New(config) } case FeatureFlagsBlock: decoded := terragruntFeatureFlags{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if output.FeatureFlags != nil { flags, err := deepMergeFeatureBlocks(output.FeatureFlags, decoded.FeatureFlags) if err != nil { return nil, err } output.FeatureFlags = flags } else { output.FeatureFlags = decoded.FeatureFlags } case ExcludeBlock: hasExcludeBlock = true case ErrorsBlock: decoded := terragruntErrors{} err := file.Decode(&decoded, evalParsingContext) if err != nil { return nil, err } if output.Errors != nil { output.Errors.Merge(decoded.Errors) } else { output.Errors = decoded.Errors } default: return nil, InvalidPartialBlockName{decode} } } errsContainsIncludeErr := false for _, err := range errs.WrappedErrors() { if errors.As(err, &TooManyLevelsOfInheritanceError{}) { errsContainsIncludeErr = true } } // If this file includes another, parse and merge the partial blocks. Otherwise, just return this config. // If there have been errors during this parse, don't attempt to parse the included config. if len(pctx.TrackInclude.CurrentList) > 0 && !errsContainsIncludeErr { includeCount := len(pctx.TrackInclude.CurrentList) includePaths := make([]string, 0, includeCount) for _, inc := range pctx.TrackInclude.CurrentList { if inc.Path != "" { includePaths = append(includePaths, inc.Path) } } var config *TerragruntConfig err := TraceParseIncludeMerge(ctx, file.ConfigPath, includeCount, includePaths, func(ctx context.Context) error { var mergeErr error config, mergeErr = handleInclude(ctx, pctx, l, output, true) return mergeErr }) if err != nil { errs = errs.Append(err) } // Saving processed includes into configuration, direct assignment since nested includes aren't supported config.ProcessedIncludes = pctx.TrackInclude.CurrentMap output = config } if errs.ErrorOrNil() != nil { return output, errs.ErrorOrNil() } if hasExcludeBlock { return processExcludes(ctx, pctx, l, output, file) } return output, nil } // processExcludes evaluate exclude blocks and merge them into the config. func processExcludes(ctx context.Context, pctx *ParsingContext, l log.Logger, config *TerragruntConfig, file *hclparse.File) (*TerragruntConfig, error) { flagsAsCtyVal, err := flagsAsCty(pctx, config.FeatureFlags) if err != nil { return nil, err } excludeConfig, err := evaluateExcludeBlocks(ctx, pctx.WithFeatures(&flagsAsCtyVal), l, file) if err != nil { return nil, err } if excludeConfig == nil { return config, nil } if config.Exclude != nil { config.Exclude.Merge(excludeConfig) } else { config.Exclude = excludeConfig } return config, nil } func partialParseIncludedConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, includedConfig *IncludeConfig) (*TerragruntConfig, error) { if includedConfig.Path == "" { return nil, errors.New(IncludedConfigMissingPathError(pctx.TerragruntConfigPath)) } includePath := includedConfig.Path if !filepath.IsAbs(includePath) { includePath = filepath.Join(filepath.Dir(pctx.TerragruntConfigPath), includePath) } config, err := PartialParseConfigFile( ctx, pctx, l, includePath, includedConfig, ) if err != nil { // Convert generic config not found error to include-specific error var configNotFoundError TerragruntConfigNotFoundError if errors.As(err, &configNotFoundError) { return nil, IncludeConfigNotFoundError{IncludePath: includePath, SourcePath: pctx.TerragruntConfigPath} } return nil, err } return config, nil } // This decodes only the `include` blocks of a terragrunt config, so its value can be used while decoding the rest of // the config. // For consistency, `include` in the call to `file.Decode` is always assumed to be nil. Either it really is nil (parsing // the child config), or it shouldn't be used anyway (the parent config shouldn't have an include block). func decodeAsTerragruntInclude(file *hclparse.File, evalParsingContext *hcl.EvalContext) (IncludeConfigs, error) { tgInc := terragruntIncludeMultiple{} if err := file.Decode(&tgInc, evalParsingContext); err != nil { return nil, err } return tgInc.Include, nil } // Custom error types type InvalidPartialBlockName struct { sectionCode PartialDecodeSectionType } func (err InvalidPartialBlockName) Error() string { return fmt.Sprintf("Unrecognized partial block code %d. This is most likely an error in terragrunt. Please file a bug report on the project repository.", err.sectionCode) } ================================================ FILE: pkg/config/config_partial_test.go ================================================ package config_test import ( "context" "fmt" "os" "path/filepath" "strconv" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func TestPartialParseResolvesLocals(t *testing.T) { t.Parallel() cfg := ` locals { app1 = "../app1" } dependencies { paths = [local.app1] } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependenciesBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 1) assert.Equal(t, "../app1", terragruntConfig.Dependencies.Paths[0]) assert.Equal(t, map[string]any{"app1": "../app1"}, terragruntConfig.Locals) assert.Nil(t, terragruntConfig.PreventDestroy) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Inputs) } func TestPartialParseDoesNotResolveIgnoredBlock(t *testing.T) { t.Parallel() cfg := ` dependencies { # This function call will fail when attempting to decode paths = [file("i-am-a-file-that-does-not-exist")] } prevent_destroy = false ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) _, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) pctx = pctx.WithDecodeList(config.DependenciesBlock) _, err = config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) assert.Error(t, err) } func TestPartialParseMultipleItems(t *testing.T) { t.Parallel() cfg := ` dependencies { paths = ["../app1"] } prevent_destroy = true skip = true ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependenciesBlock, config.TerragruntFlags) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 1) assert.Equal(t, "../app1", terragruntConfig.Dependencies.Paths[0]) assert.True(t, *terragruntConfig.PreventDestroy) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Inputs) assert.Nil(t, terragruntConfig.Locals) } func TestPartialParseOmittedItems(t *testing.T) { t.Parallel() l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependenciesBlock, config.TerragruntFlags) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, "", nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.Nil(t, terragruntConfig.Dependencies) assert.Nil(t, terragruntConfig.PreventDestroy) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Inputs) assert.Nil(t, terragruntConfig.Locals) } func TestPartialParseDoesNotResolveIgnoredBlockEvenInParent(t *testing.T) { t.Parallel() configPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "partial-parse", "ignore-bad-block-in-parent", "child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, configPath) pctx = pctx.WithDecodeList(config.TerragruntFlags) _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) pctx = pctx.WithDecodeList(config.DependenciesBlock) _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) assert.Error(t, err) } func TestPartialParseOnlyInheritsSelectedBlocksFlags(t *testing.T) { t.Parallel() configPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "partial-parse", "partial-inheritance", "child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, configPath) pctx = pctx.WithDecodeList(config.TerragruntFlags) terragruntConfig, err := config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.Nil(t, terragruntConfig.Dependencies) assert.True(t, *terragruntConfig.PreventDestroy) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Inputs) assert.Nil(t, terragruntConfig.Locals) } func TestPartialParseOnlyInheritsSelectedBlocksDependencies(t *testing.T) { t.Parallel() configPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "partial-parse", "partial-inheritance", "child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, configPath) pctx = pctx.WithDecodeList(config.DependenciesBlock) terragruntConfig, err := config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 1) assert.Equal(t, "../app1", terragruntConfig.Dependencies.Paths[0]) assert.Nil(t, terragruntConfig.PreventDestroy) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Inputs) assert.Nil(t, terragruntConfig.Locals) } func TestPartialParseDependencyBlockSetsTerragruntDependencies(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.TerragruntDependencies) assert.Len(t, terragruntConfig.TerragruntDependencies, 1) assert.Equal(t, "vpc", terragruntConfig.TerragruntDependencies[0].Name) assert.Equal(t, cty.StringVal("../app1"), terragruntConfig.TerragruntDependencies[0].ConfigPath) } func TestPartialParseMultipleDependencyBlockSetsTerragruntDependencies(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } dependency "sql" { config_path = "../db1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.TerragruntDependencies) assert.Len(t, terragruntConfig.TerragruntDependencies, 2) assert.Equal(t, "vpc", terragruntConfig.TerragruntDependencies[0].Name) assert.Equal(t, cty.StringVal("../app1"), terragruntConfig.TerragruntDependencies[0].ConfigPath) assert.Equal(t, "sql", terragruntConfig.TerragruntDependencies[1].Name) assert.Equal(t, cty.StringVal("../db1"), terragruntConfig.TerragruntDependencies[1].ConfigPath) } func TestPartialParseDependencyBlockSetsDependencies(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } dependency "sql" { config_path = "../db1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 2) assert.Equal(t, []string{"../app1", "../db1"}, terragruntConfig.Dependencies.Paths) } func TestPartialParseDependencyBlockMergesDependencies(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } dependencies { paths = ["../vpc"] } dependency "sql" { config_path = "../db1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependenciesBlock, config.DependencyBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 3) assert.Equal(t, []string{"../vpc", "../app1", "../db1"}, terragruntConfig.Dependencies.Paths) } func TestPartialParseDependencyBlockMergesDependenciesOrdering(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } dependencies { paths = ["../vpc"] } dependency "sql" { config_path = "../db1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock, config.DependenciesBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 3) assert.Equal(t, []string{"../app1", "../db1", "../vpc"}, terragruntConfig.Dependencies.Paths) } func TestPartialParseDependencyBlockMergesDependenciesDedup(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../app1" } dependencies { paths = ["../app1"] } dependency "sql" { config_path = "../db1" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock, config.DependenciesBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Dependencies) assert.Len(t, terragruntConfig.Dependencies.Paths, 2) assert.Equal(t, []string{"../app1", "../db1"}, terragruntConfig.Dependencies.Paths) } func TestPartialParseOnlyParsesTerraformSource(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../vpc" } terraform { source = "../../modules/app" before_hook "before" { commands = ["apply"] execute = ["echo", dependency.vpc.outputs.vpc_id] } } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.TerraformSource) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.True(t, terragruntConfig.IsPartial) assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "../../modules/app", *terragruntConfig.Terraform.Source) } func TestOptionalDependenciesAreSkipped(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../vpc" } dependency "ec2" { config_path = "../ec2" enabled = false } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.Len(t, terragruntConfig.Dependencies.Paths, 1) } func TestPartialParseSavesToHclCache(t *testing.T) { t.Parallel() // Setup test environment tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") configContent := `dependencies { paths = ["../app1"] }` //nolint:goconst require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) // Get file metadata for cache key generation fileInfo, err := os.Stat(configPath) require.NoError(t, err) expectedCacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) // Setup cache and context hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) ctx = context.WithValue(ctx, config.HclCacheContextKey, hclCache) pctx = pctx.WithDecodeList(config.DependenciesBlock) // Verify cache is empty initially _, found := hclCache.Get(ctx, expectedCacheKey) require.False(t, found, "cache should be empty before parsing") // Parse config file (should populate cache) _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify file was cached cachedFile, found := hclCache.Get(ctx, expectedCacheKey) require.True(t, found, "expected file to be in cache after first parse") require.NotNil(t, cachedFile, "cached file should not be nil") // Verify cached content matches the original assert.Equal(t, configPath, cachedFile.ConfigPath) assert.Contains(t, cachedFile.Content(), "dependencies") } func TestPartialParseCacheHitOnSecondParse(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") configContent := `dependencies { paths = ["../app1"] }` require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) fileInfo, err := os.Stat(configPath) require.NoError(t, err) cacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) ctx = context.WithValue(ctx, config.HclCacheContextKey, hclCache) pctx = pctx.WithDecodeList(config.DependenciesBlock) // First parse - should be cache miss _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify cache hit on second parse _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify same file object is returned from cache cachedFile, found := hclCache.Get(ctx, cacheKey) require.True(t, found) require.NotNil(t, cachedFile) } func TestPartialParseCacheInvalidationOnFileModification(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") originalContent := `dependencies { paths = ["../app1"] }` modifiedContent := `dependencies { paths = ["../app1", "../app2"] }` require.NoError(t, os.WriteFile(configPath, []byte(originalContent), 0644)) fileInfo, err := os.Stat(configPath) require.NoError(t, err) originalCacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) ctx = context.WithValue(ctx, config.HclCacheContextKey, hclCache) pctx = pctx.WithDecodeList(config.DependenciesBlock) // Parse original file _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify original file is cached _, found := hclCache.Get(ctx, originalCacheKey) require.True(t, found, "original file should be cached") // Modify file (this changes mod time) require.NoError(t, os.WriteFile(configPath, []byte(modifiedContent), 0644)) forceModTimeChange(t, configPath, fileInfo.ModTime()) // Parse modified file - should create new cache entry _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify old cache entry is still there but new one exists _, found = hclCache.Get(ctx, originalCacheKey) require.True(t, found, "original cache entry should still exist") // Get new cache key fileInfo, err = os.Stat(configPath) require.NoError(t, err) newCacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) // Verify new file is cached with different content newCachedFile, found := hclCache.Get(ctx, newCacheKey) require.True(t, found, "modified file should be cached") require.NotNil(t, newCachedFile) assert.Contains(t, newCachedFile.Content(), "../app2") } func TestPartialParseCacheWithInvalidFile(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") invalidContent := `invalid hcl syntax {` require.NoError(t, os.WriteFile(configPath, []byte(invalidContent), 0644)) hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) ctx = context.WithValue(ctx, config.HclCacheContextKey, hclCache) pctx = pctx.WithDecodeList(config.DependenciesBlock) // Parse should fail and not cache an invalid file _, err := config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.Error(t, err, "parsing invalid HCL should fail") // Verify nothing was cached fileInfo, err := os.Stat(configPath) require.NoError(t, err) cacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) _, found := hclCache.Get(ctx, cacheKey) require.False(t, found, "invalid file should not be cached") } func TestPartialParseCacheKeyFormat(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) configPath := filepath.Join(tmpDir, "terragrunt.hcl") configContent := `dependencies { paths = ["../app1"] }` require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) fileInfo, err := os.Stat(configPath) require.NoError(t, err) expectedCacheKey := fmt.Sprintf("configPath-%v-modTime-%v", configPath, fileInfo.ModTime().UnixMicro()) hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) ctx = context.WithValue(ctx, config.HclCacheContextKey, hclCache) pctx = pctx.WithDecodeList(config.DependenciesBlock) _, err = config.PartialParseConfigFile(ctx, pctx, l, configPath, nil) require.NoError(t, err) // Verify cache key format matches the expected pattern assert.Regexp(t, `^configPath-.*-modTime-\d+$`, expectedCacheKey, "cache key should match expected format") assert.Contains(t, expectedCacheKey, configPath, "cache key should contain config path") assert.Contains(t, expectedCacheKey, strconv.FormatInt(fileInfo.ModTime().UnixMicro(), 10), "cache key should contain mod time") // Verify we can retrieve using the expected key _, found := hclCache.Get(ctx, expectedCacheKey) require.True(t, found, "should be able to retrieve using expected cache key format") } // forceModTimeChange ensures the file at path has a modification time strictly after prev. func forceModTimeChange(t *testing.T, path string, prev time.Time) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { err := os.Chtimes(path, time.Now(), time.Now()) require.NoError(t, err) if fileInfo, err := os.Stat(path); err == nil && fileInfo.ModTime().After(prev) { return } time.Sleep(1 * time.Millisecond) } t.Fatalf("Failed to change modification time of %s within 5 seconds", path) } // TestPartialParseConfigCacheDifferentCallers verifies that the partial parse config cache // creates separate entries for different calling modules parsing the same file. // This prevents cross-environment dependency bugs where context-sensitive functions // (e.g. path_relative_to_include) return wrong values from a cached result. func TestPartialParseConfigCacheDifferentCallers(t *testing.T) { t.Parallel() // Create a shared config file that both modules will parse. tmpDir := helpers.TmpDirWOSymlinks(t) sharedConfigPath := filepath.Join(tmpDir, "shared.hcl") sharedContent := `dependencies { paths = ["../app1"] }` require.NoError(t, os.WriteFile(sharedConfigPath, []byte(sharedContent), 0644)) // Create two different module directories with distinct config paths. moduleADir := filepath.Join(tmpDir, "moduleA") moduleBDir := filepath.Join(tmpDir, "moduleB") require.NoError(t, os.MkdirAll(moduleADir, 0755)) require.NoError(t, os.MkdirAll(moduleBDir, 0755)) moduleAConfigPath := filepath.Join(moduleADir, "terragrunt.hcl") moduleBConfigPath := filepath.Join(moduleBDir, "terragrunt.hcl") require.NoError(t, os.WriteFile(moduleAConfigPath, []byte(""), 0644)) require.NoError(t, os.WriteFile(moduleBConfigPath, []byte(""), 0644)) // Setup shared caches in context so both modules use the same config cache. hclCache := cache.NewCache[*hclparse.File]("test-hcl-cache") configCache := cache.NewCache[*config.TerragruntConfig]("test-config-cache") l := logger.CreateLogger() // Parse shared config from module A's context. ctxA, pctxA := newTestParsingContext(t, moduleAConfigPath) ctxA = context.WithValue(ctxA, config.HclCacheContextKey, hclCache) ctxA = context.WithValue(ctxA, config.TerragruntConfigCacheContextKey, configCache) pctxA.UsePartialParseConfigCache = true pctxA = pctxA.WithDecodeList(config.DependenciesBlock) configA, err := config.PartialParseConfigFile(ctxA, pctxA, l, sharedConfigPath, nil) require.NoError(t, err) require.NotNil(t, configA) // Parse shared config from module B's context (different TerragruntConfigPath). ctxB, pctxB := newTestParsingContext(t, moduleBConfigPath) ctxB = context.WithValue(ctxB, config.HclCacheContextKey, hclCache) ctxB = context.WithValue(ctxB, config.TerragruntConfigCacheContextKey, configCache) pctxB.UsePartialParseConfigCache = true pctxB = pctxB.WithDecodeList(config.DependenciesBlock) configB, err := config.PartialParseConfigFile(ctxB, pctxB, l, sharedConfigPath, nil) require.NoError(t, err) require.NotNil(t, configB) // Verify that two separate cache entries were created (one per caller), // not a single shared entry. This ensures context-sensitive functions like // path_relative_to_include() would evaluate correctly for each caller. configCache.Mutex.RLock() cacheLen := len(configCache.Cache) configCache.Mutex.RUnlock() assert.Equal(t, 2, cacheLen, "config cache should have 2 entries (one per calling module), not 1") // Both should return valid results. assert.Equal(t, []string{"../app1"}, configA.Dependencies.Paths) assert.Equal(t, []string{"../app1"}, configB.Dependencies.Paths) } ================================================ FILE: pkg/config/config_test.go ================================================ package config_test import ( "bytes" "fmt" "os" "path/filepath" "sort" "strings" "testing" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func createLogger() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)) } func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) { t.Parallel() cfg := ` remote_state { backend = "s3" config = {} encryption = {} } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.Empty(t, terragruntConfig.RemoteState.BackendConfig) assert.Empty(t, terragruntConfig.RemoteState.Encryption) } } func TestParseTerragruntConfigRemoteStateAttrMinimalConfig(t *testing.T) { t.Parallel() cfg := ` remote_state = { backend = "s3" config = {} } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.Empty(t, terragruntConfig.RemoteState.BackendConfig) } } func TestParseTerragruntConfigRemoteStateAttrStringBoolCoercion(t *testing.T) { t.Parallel() cfg := ` locals { enable_flags = true } remote_state = { backend = "s3" disable_init = local.enable_flags ? "true" : "false" disable_dependency_optimization = local.enable_flags ? "false" : "true" config = { bucket = "my-bucket" key = "terraform.tfstate" region = "us-east-1" } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.True(t, terragruntConfig.RemoteState.DisableInit) assert.False(t, terragruntConfig.RemoteState.DisableDependencyOptimization) } } // TestParseTerragruntConfigRemoteStateTernaryUseLockfile reproduces issue #5646: // when remote_state.config is produced from a local resolved with a ternary operator, // HCL type unification converts bool values (like use_lockfile) to strings. // The S3 backend must normalize these back to native bools before codegen. func TestParseTerragruntConfigRemoteStateTernaryUseLockfile(t *testing.T) { t.Parallel() cfg := ` locals { is_prod = true remote_state_config = local.is_prod ? { bucket = "prod-bucket" key = "terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } : { bucket = "dev-bucket" key = "terraform.tfstate" region = "us-west-2" encrypt = true use_lockfile = true } } remote_state { backend = "s3" config = local.remote_state_config } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.Equal(t, "prod-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) // After parsing, verify S3 backend normalizes string bools in GetTFInitArgs s3Backend := s3.NewBackend() initArgs := s3Backend.GetTFInitArgs(terragruntConfig.RemoteState.BackendConfig) assert.IsType(t, true, initArgs["use_lockfile"]) } } func TestParseTerragruntConfigRemoteStateAttrInvalidStringBool(t *testing.T) { t.Parallel() cfg := ` remote_state = { backend = "s3" disable_init = "maybe" config = {} } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) } func TestParseTerragruntConfigGenerateAttrStringBoolCoercion(t *testing.T) { t.Parallel() cfg := ` locals { enable_flags = true } generate = { provider = { path = "provider.tf" if_exists = "overwrite" contents = "provider \"aws\" {}" disable_signature = local.enable_flags ? "true" : "false" disable = local.enable_flags ? "false" : "true" } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) providerGenerateConfig, ok := terragruntConfig.GenerateConfigs["provider"] require.True(t, ok) assert.True(t, providerGenerateConfig.DisableSignature) assert.False(t, providerGenerateConfig.Disable) } func TestParseTerragruntConfigGenerateAttrInvalidStringBool(t *testing.T) { t.Parallel() cfg := ` generate = { provider = { path = "provider.tf" if_exists = "overwrite" contents = "provider \"aws\" {}" disable_signature = "maybe" } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) } func TestParseTerragruntJsonConfigRemoteStateMinimalConfig(t *testing.T) { t.Parallel() cfg := ` { "remote_state": { "backend": "s3", "config": {} } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntJSONConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.Empty(t, terragruntConfig.RemoteState.BackendConfig) } } func TestParseTerragruntHclConfigRemoteStateMissingBackend(t *testing.T) { t.Parallel() cfg := ` remote_state {} ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "Missing required argument; The argument \"backend\" is required") } func TestParseTerragruntHclConfigRemoteStateFullConfig(t *testing.T) { t.Parallel() cfg := ` remote_state { backend = "s3" config = { encrypt = true bucket = "my-bucket" key = "terraform.tfstate" region = "us-east-1" } encryption = { key_provider = "pbkdf2" passphrase = "correct-horse-battery-staple" } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"]) assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"]) } } func TestParseTerragruntJsonConfigRemoteStateFullConfig(t *testing.T) { t.Parallel() cfg := ` { "remote_state":{ "backend":"s3", "config":{ "encrypt": true, "bucket": "my-bucket", "key": "terraform.tfstate", "region":"us-east-1" }, "encryption":{ "key_provider": "pbkdf2", "passphrase": "correct-horse-battery-staple" } } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntJSONConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"]) assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"]) } } func TestParseTerragruntHclConfigRetryConfiguration(t *testing.T) { t.Parallel() // All three legacy retry attributes should be rejected cfg := ` retryable_errors = [".*Error.*"] ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "retryable_errors") assert.Contains(t, err.Error(), "Unsupported argument") } func TestParseTerragruntJsonConfigRetryConfiguration(t *testing.T) { t.Parallel() cfg := ` { "retryable_errors": [".*Error.*"] } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntJSONConfigPath, cfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "retryable_errors") // JSON config gives a slightly different error message assert.True(t, strings.Contains(err.Error(), "Unsupported argument") || strings.Contains(err.Error(), "No argument or block type")) } func TestParseIamRole(t *testing.T) { t.Parallel() cfg := `iam_role = "terragrunt-iam-role"` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.Dependencies) assert.Equal(t, "terragrunt-iam-role", terragruntConfig.IamRole) } func TestParseIamAssumeRoleDuration(t *testing.T) { t.Parallel() cfg := `iam_assume_role_duration = 36000` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.Dependencies) assert.Equal(t, int64(36000), *terragruntConfig.IamAssumeRoleDuration) } func TestParseIamAssumeRoleSessionName(t *testing.T) { t.Parallel() cfg := `iam_assume_role_session_name = "terragrunt-iam-assume-role-session-name"` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.Dependencies) assert.Equal(t, "terragrunt-iam-assume-role-session-name", terragruntConfig.IamAssumeRoleSessionName) } func TestParseIamWebIdentity(t *testing.T) { t.Parallel() token := "test-token" cfg := fmt.Sprintf(`iam_web_identity_token = "%s"`, token) l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.Dependencies) assert.Empty(t, terragruntConfig.IamRole) assert.Equal(t, token, terragruntConfig.IamWebIdentityToken) } func TestParseTerragruntConfigDependenciesOnePath(t *testing.T) { t.Parallel() cfg := ` dependencies { paths = ["../../test/fixtures/parent-folders/multiple-terragrunt-in-parents"] } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.Dependencies) { assert.Equal(t, []string{"../../test/fixtures/parent-folders/multiple-terragrunt-in-parents"}, terragruntConfig.Dependencies.Paths) } } func TestParseTerragruntConfigDependenciesMultiplePaths(t *testing.T) { t.Parallel() cfg := ` dependencies { paths = ["../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"] } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Terraform) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.Dependencies) { assert.Equal(t, []string{"../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"}, terragruntConfig.Dependencies.Paths) } } func TestParseTerragruntConfigRemoteStateDynamoDbTerraformConfigAndDependenciesFullConfig(t *testing.T) { t.Parallel() cfg := ` terraform { source = "foo" } remote_state { backend = "s3" config = { encrypt = true bucket = "my-bucket" key = "terraform.tfstate" region = "us-east-1" } } dependencies { paths = ["../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"] } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "foo", *terragruntConfig.Terraform.Source) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) } if assert.NotNil(t, terragruntConfig.Dependencies) { assert.Equal(t, []string{"../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"}, terragruntConfig.Dependencies.Paths) } } func TestParseTerragruntJsonConfigRemoteStateDynamoDbTerraformConfigAndDependenciesFullConfig(t *testing.T) { t.Parallel() cfg := ` { "terraform": { "source": "foo" }, "remote_state": { "backend": "s3", "config": { "encrypt": true, "bucket": "my-bucket", "key": "terraform.tfstate", "region": "us-east-1" } }, "dependencies":{ "paths": ["../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"] } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntJSONConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "foo", *terragruntConfig.Terraform.Source) assert.Empty(t, terragruntConfig.IamRole) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) } if assert.NotNil(t, terragruntConfig.Dependencies) { assert.Equal(t, []string{"../../test/fixtures/terragrunt", "../../test/fixtures/dirs", "../../test/fixtures/inputs"}, terragruntConfig.Dependencies.Paths) } } func TestParseTerragruntConfigInclude(t *testing.T) { t.Parallel() cfg := fmt.Sprintf(` include { path = "../../../%s" } `, "root.hcl") cfgPath := "../../test/fixtures/parent-folders/terragrunt-in-root/child/sub-child/sub-sub-child/" + config.DefaultTerragruntConfigPath l := createLogger() ctx, pctx := newTestParsingContext(t, cfgPath) terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, cfgPath, cfg, nil) if assert.NoError(t, err, "Unexpected error: %v", errors.New(err)) { assert.Nil(t, terragruntConfig.Terraform) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "child/sub-child/sub-sub-child/terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) } } } func TestParseTerragruntConfigIncludeWithFindInParentFolders(t *testing.T) { t.Parallel() cfg := ` include { path = find_in_parent_folders("root.hcl") } ` cfgPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) l := createLogger() ctx, pctx := newTestParsingContext(t, cfgPath) terragruntConfig, parseErr := config.ParseConfigString(ctx, pctx, l, cfgPath, cfg, nil) if assert.NoError(t, parseErr, "Unexpected error: %v", errors.New(parseErr)) { assert.Nil(t, terragruntConfig.Terraform) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, true, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "child/sub-child/sub-sub-child/terraform.tfstate", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.BackendConfig["region"]) } } } func TestParseTerragruntConfigIncludeOverrideRemote(t *testing.T) { t.Parallel() cfg := fmt.Sprintf(` include { path = "../../../%s" } # Configure Terragrunt to automatically store tfstate files in an S3 bucket remote_state { backend = "s3" config = { encrypt = false bucket = "override" key = "override" region = "override" } } `, "root.hcl") cfgPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) l := createLogger() ctx, pctx := newTestParsingContext(t, cfgPath) terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, cfgPath, cfg, nil) if assert.NoError(t, err, "Unexpected error: %v", errors.New(err)) { assert.Nil(t, terragruntConfig.Terraform) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, false, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["region"]) } } } func TestParseTerragruntConfigIncludeOverrideAll(t *testing.T) { t.Parallel() cfg := fmt.Sprintf(` include { path = "../../../%s" } terraform { source = "foo" } # Configure Terragrunt to automatically store tfstate files in an S3 bucket remote_state { backend = "s3" config = { encrypt = false bucket = "override" key = "override" region = "override" } } dependencies { paths = ["override"] } `, "root.hcl") configPath := "../../test/fixtures/parent-folders/terragrunt-in-root/child/sub-child/sub-sub-child/" + config.DefaultTerragruntConfigPath l := createLogger() ctx, pctx := newTestParsingContext(t, configPath) terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, configPath, cfg, nil) require.NoError(t, err, "Unexpected error: %v", errors.New(err)) assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "foo", *terragruntConfig.Terraform.Source) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, false, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["region"]) } assert.Equal(t, []string{"override"}, terragruntConfig.Dependencies.Paths) } func TestParseTerragruntJsonConfigIncludeOverrideAll(t *testing.T) { t.Parallel() cfg := fmt.Sprintf(` { "include":{ "path": "../../../%s" }, "terraform":{ "source": "foo" }, "remote_state":{ "backend": "s3", "config":{ "encrypt": false, "bucket": "override", "key": "override", "region": "override" } }, "dependencies":{ "paths": ["override"] } } `, "root.hcl") cfgPath := "../../test/fixtures/parent-folders/terragrunt-in-root/child/sub-child/sub-sub-child/" + config.DefaultTerragruntJSONConfigPath l := createLogger() ctx, pctx := newTestParsingContext(t, cfgPath) terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, cfgPath, cfg, nil) require.NoError(t, err, "Unexpected error: %v", errors.New(err)) assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "foo", *terragruntConfig.Terraform.Source) if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.BackendName) assert.NotEmpty(t, terragruntConfig.RemoteState.BackendConfig) assert.Equal(t, false, terragruntConfig.RemoteState.BackendConfig["encrypt"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["bucket"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["key"]) assert.Equal(t, "override", terragruntConfig.RemoteState.BackendConfig["region"]) } assert.Equal(t, []string{"override"}, terragruntConfig.Dependencies.Paths) } func TestParseTerragruntConfigTwoLevels(t *testing.T) { t.Parallel() configPathRel := "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/" + config.RecommendedParentConfigName configPath := absPath(t, configPathRel) cfg, err := util.ReadFileAsString(configPathRel) if err != nil { t.Fatal(err) } l := createLogger() ctx, pctx := newTestParsingContext(t, configPath) _, actualErr := config.ParseConfigString(ctx, pctx, l, configPath, cfg, nil) errStr := actualErr.Error() expectedErrPath := absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/"+config.RecommendedParentConfigName) expectedErrStr := fmt.Sprintf("%s includes %s, which itself includes %s. Only one level of includes is allowed.", configPath, expectedErrPath, expectedErrPath) assert.Contains(t, errStr, expectedErrStr) } func TestParseTerragruntConfigThreeLevels(t *testing.T) { t.Parallel() configPathRel := "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/sub-sub-child/" + config.DefaultTerragruntConfigPath configPath := absPath(t, configPathRel) cfg, err := util.ReadFileAsString(configPathRel) if err != nil { t.Fatal(err) } l := createLogger() ctx, pctx := newTestParsingContext(t, configPath) _, actualErr := config.ParseConfigString(ctx, pctx, l, configPath, cfg, nil) errStr := actualErr.Error() // Build expected error string expectedErrPath1 := absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/"+config.RecommendedParentConfigName) expectedErrPath2 := absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/"+config.RecommendedParentConfigName) expectedErrStr := fmt.Sprintf("%s includes %s, which itself includes %s. Only one level of includes is allowed.", configPath, expectedErrPath1, expectedErrPath2) assert.Contains(t, errStr, expectedErrStr) } func TestParseTerragruntConfigEmptyConfig(t *testing.T) { t.Parallel() cfg := `` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) assert.Nil(t, terragruntConfig.PreventDestroy) assert.Empty(t, terragruntConfig.IamRole) assert.Empty(t, terragruntConfig.IamWebIdentityToken) } func TestParseTerragruntConfigEmptyConfigOldConfig(t *testing.T) { t.Parallel() cfgString := `` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") cfg, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfgString, nil) if err != nil { t.Fatal(err) } assert.Nil(t, cfg.RemoteState) } func TestParseTerragruntConfigTerraformNoSource(t *testing.T) { t.Parallel() cfg := ` terraform {} ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) assert.NotNil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.Terraform.Source) } func TestParseTerragruntConfigTerraformWithSource(t *testing.T) { t.Parallel() cfg := ` terraform { source = "foo" } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) assert.NotNil(t, terragruntConfig.Terraform) assert.NotNil(t, terragruntConfig.Terraform.Source) assert.Equal(t, "foo", *terragruntConfig.Terraform.Source) } func TestParseTerragruntConfigTerraformWithExtraArguments(t *testing.T) { t.Parallel() cfg := ` terraform { extra_arguments "secrets" { arguments = [ "-var-file=terraform.tfvars", "-var-file=terraform-secret.tfvars" ] commands = get_terraform_commands_that_need_vars() env_vars = { TEST_VAR = "value" } } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) if assert.NotNil(t, terragruntConfig.Terraform) { assert.Equal(t, "secrets", terragruntConfig.Terraform.ExtraArgs[0].Name) assert.Equal(t, &[]string{ "-var-file=terraform.tfvars", "-var-file=terraform-secret.tfvars", }, terragruntConfig.Terraform.ExtraArgs[0].Arguments) assert.Equal(t, config.TerraformCommandsNeedVars, terragruntConfig.Terraform.ExtraArgs[0].Commands) assert.Equal(t, &map[string]string{"TEST_VAR": "value"}, terragruntConfig.Terraform.ExtraArgs[0].EnvVars) } } func TestParseTerragruntConfigTerraformWithMultipleExtraArguments(t *testing.T) { t.Parallel() cfg := ` terraform { extra_arguments "json_output" { arguments = ["-json"] commands = ["output"] } extra_arguments "fmt_diff" { arguments = ["-diff=true"] commands = ["fmt"] } extra_arguments "required_tfvars" { required_var_files = [ "file1.tfvars", "file2.tfvars" ] commands = get_terraform_commands_that_need_vars() } extra_arguments "optional_tfvars" { optional_var_files = [ "opt1.tfvars", "opt2.tfvars" ] commands = get_terraform_commands_that_need_vars() } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) if assert.NotNil(t, terragruntConfig.Terraform) { assert.Equal(t, "json_output", terragruntConfig.Terraform.ExtraArgs[0].Name) assert.Equal(t, &[]string{"-json"}, terragruntConfig.Terraform.ExtraArgs[0].Arguments) assert.Equal(t, []string{"output"}, terragruntConfig.Terraform.ExtraArgs[0].Commands) assert.Equal(t, "fmt_diff", terragruntConfig.Terraform.ExtraArgs[1].Name) assert.Equal(t, &[]string{"-diff=true"}, terragruntConfig.Terraform.ExtraArgs[1].Arguments) assert.Equal(t, []string{"fmt"}, terragruntConfig.Terraform.ExtraArgs[1].Commands) assert.Equal(t, "required_tfvars", terragruntConfig.Terraform.ExtraArgs[2].Name) assert.Equal(t, &[]string{"file1.tfvars", "file2.tfvars"}, terragruntConfig.Terraform.ExtraArgs[2].RequiredVarFiles) assert.Equal(t, config.TerraformCommandsNeedVars, terragruntConfig.Terraform.ExtraArgs[2].Commands) assert.Equal(t, "optional_tfvars", terragruntConfig.Terraform.ExtraArgs[3].Name) assert.Equal(t, &[]string{"opt1.tfvars", "opt2.tfvars"}, terragruntConfig.Terraform.ExtraArgs[3].OptionalVarFiles) assert.Equal(t, config.TerraformCommandsNeedVars, terragruntConfig.Terraform.ExtraArgs[3].Commands) } } func TestParseTerragruntJsonConfigTerraformWithMultipleExtraArguments(t *testing.T) { t.Parallel() cfg := ` { "terraform":{ "extra_arguments":{ "json_output":{ "arguments": ["-json"], "commands": ["output"] }, "fmt_diff":{ "arguments": ["-diff=true"], "commands": ["fmt"] }, "required_tfvars":{ "required_var_files":[ "file1.tfvars", "file2.tfvars" ], "commands": "${get_terraform_commands_that_need_vars()}" }, "optional_tfvars":{ "optional_var_files":[ "opt1.tfvars", "opt2.tfvars" ], "commands": "${get_terraform_commands_that_need_vars()}" } } } } ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntJSONConfigPath, cfg, nil) require.NoError(t, err) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) if assert.NotNil(t, terragruntConfig.Terraform) { assert.Equal(t, "json_output", terragruntConfig.Terraform.ExtraArgs[0].Name) assert.Equal(t, &[]string{"-json"}, terragruntConfig.Terraform.ExtraArgs[0].Arguments) assert.Equal(t, []string{"output"}, terragruntConfig.Terraform.ExtraArgs[0].Commands) assert.Equal(t, "fmt_diff", terragruntConfig.Terraform.ExtraArgs[1].Name) assert.Equal(t, &[]string{"-diff=true"}, terragruntConfig.Terraform.ExtraArgs[1].Arguments) assert.Equal(t, []string{"fmt"}, terragruntConfig.Terraform.ExtraArgs[1].Commands) assert.Equal(t, "required_tfvars", terragruntConfig.Terraform.ExtraArgs[2].Name) assert.Equal(t, &[]string{"file1.tfvars", "file2.tfvars"}, terragruntConfig.Terraform.ExtraArgs[2].RequiredVarFiles) assert.Equal(t, config.TerraformCommandsNeedVars, terragruntConfig.Terraform.ExtraArgs[2].Commands) assert.Equal(t, "optional_tfvars", terragruntConfig.Terraform.ExtraArgs[3].Name) assert.Equal(t, &[]string{"opt1.tfvars", "opt2.tfvars"}, terragruntConfig.Terraform.ExtraArgs[3].OptionalVarFiles) assert.Equal(t, config.TerraformCommandsNeedVars, terragruntConfig.Terraform.ExtraArgs[3].Commands) } } func testDownloadDir(tb testing.TB, configPath string) string { tb.Helper() _, downloadDir := util.DefaultWorkingAndDownloadDirs(configPath) return downloadDir } func TestFindConfigFilesInPathNone(t *testing.T) { t.Parallel() expected := []string{} actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/none", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, expected, actual) } func TestFindConfigFilesInPathOneConfig(t *testing.T) { t.Parallel() expected := []string{"../../test/fixtures/config-files/one-config/subdir/terragrunt.hcl"} actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/one-config", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, expected, actual) } func TestFindConfigFilesInPathOneJsonConfig(t *testing.T) { t.Parallel() expected := []string{"../../test/fixtures/config-files/one-json-config/subdir/terragrunt.hcl.json"} actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/one-json-config", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, expected, actual) } func TestFindConfigFilesInPathMultipleConfigs(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/multiple-configs/terragrunt.hcl", "../../test/fixtures/config-files/multiple-configs/subdir-2/subdir/terragrunt.hcl", "../../test/fixtures/config-files/multiple-configs/subdir-3/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/multiple-configs", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesInPathMultipleJsonConfigs(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/multiple-json-configs/terragrunt.hcl.json", "../../test/fixtures/config-files/multiple-json-configs/subdir-2/subdir/terragrunt.hcl.json", "../../test/fixtures/config-files/multiple-json-configs/subdir-3/terragrunt.hcl.json", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/multiple-json-configs", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesInPathMultipleMixedConfigs(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/multiple-mixed-configs/terragrunt.hcl.json", "../../test/fixtures/config-files/multiple-mixed-configs/subdir-2/subdir/terragrunt.hcl", "../../test/fixtures/config-files/multiple-mixed-configs/subdir-3/terragrunt.hcl.json", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/multiple-mixed-configs", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesIgnoresTerragruntCache(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/ignore-cached-config/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/ignore-cached-config", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.Equal(t, expected, actual) } func TestFindConfigFilesIgnoresTerraformDataDir(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/ignore-terraform-data-dir/.tf_data/modules/mod/terragrunt.hcl", "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/terragrunt.hcl", "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/.tf_data/modules/mod/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/ignore-terraform-data-dir", experiment.NewExperiments(), "test", map[string]string{}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesIgnoresTerraformDataDirEnv(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/terragrunt.hcl", "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/.terraform/modules/mod/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/ignore-terraform-data-dir", experiment.NewExperiments(), "test", map[string]string{"TF_DATA_DIR": ".tf_data"}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesIgnoresTerraformDataDirEnvPath(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/ignore-terraform-data-dir/.tf_data/modules/mod/terragrunt.hcl", "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/terragrunt.hcl", "../../test/fixtures/config-files/ignore-terraform-data-dir/subdir/.terraform/modules/mod/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/ignore-terraform-data-dir", experiment.NewExperiments(), "test", map[string]string{"TF_DATA_DIR": "subdir/.tf_data"}, testDownloadDir(t, "test")) require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestFindConfigFilesIgnoresTerraformDataDirEnvRoot(t *testing.T) { t.Parallel() workingDir, err := filepath.Abs(filepath.Join("..", "..", "test", "fixtures", "config-files", "ignore-terraform-data-dir")) require.NoError(t, err) actual, err := config.FindConfigFilesInPath(workingDir, experiment.NewExperiments(), workingDir, map[string]string{"TF_DATA_DIR": filepath.Join(workingDir, ".tf_data")}, testDownloadDir(t, workingDir)) require.NoError(t, err, "Unexpected error: %v", err) // Create expected paths using filepath.Join for cross-platform compatibility expected := []string{ filepath.Join(workingDir, "subdir", "terragrunt.hcl"), filepath.Join(workingDir, "subdir", ".terraform", "modules", "mod", "terragrunt.hcl"), filepath.Join(workingDir, "subdir", ".tf_data", "modules", "mod", "terragrunt.hcl"), } // Sort both slices to ensure consistent order for comparison sort.Strings(actual) sort.Strings(expected) // Compare the paths using filepath.Clean to normalize them normalizedActual := make([]string, len(actual)) normalizedExpected := make([]string, len(expected)) for i, path := range actual { normalizedActual[i] = filepath.Clean(path) } for i, path := range expected { normalizedExpected[i] = filepath.Clean(path) } assert.Equal(t, normalizedExpected, normalizedActual) } func TestFindConfigFilesIgnoresDownloadDir(t *testing.T) { t.Parallel() expected := []string{ "../../test/fixtures/config-files/multiple-configs/terragrunt.hcl", "../../test/fixtures/config-files/multiple-configs/subdir-3/terragrunt.hcl", } actual, err := config.FindConfigFilesInPath("../../test/fixtures/config-files/multiple-configs", experiment.NewExperiments(), "test", map[string]string{}, "../../test/fixtures/config-files/multiple-configs/subdir-2") require.NoError(t, err, "Unexpected error: %v", err) assert.ElementsMatch(t, expected, actual) } func TestParseTerragruntConfigPreventDestroyTrue(t *testing.T) { t.Parallel() cfg := ` prevent_destroy = true ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) assert.True(t, *terragruntConfig.PreventDestroy) } func TestParseTerragruntConfigPreventDestroyFalse(t *testing.T) { t.Parallel() cfg := ` prevent_destroy = false ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Nil(t, terragruntConfig.Terraform) assert.Nil(t, terragruntConfig.RemoteState) assert.Nil(t, terragruntConfig.Dependencies) assert.False(t, *terragruntConfig.PreventDestroy) } func TestParseTerragruntConfigSkipTrue(t *testing.T) { t.Parallel() cfg := ` skip = true ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "skip") assert.Contains(t, err.Error(), "Unsupported argument") } func TestParseTerragruntConfigSkipFalse(t *testing.T) { t.Parallel() cfg := ` skip = false ` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") _, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "skip") assert.Contains(t, err.Error(), "Unsupported argument") } func TestIncludeFunctionsWorkInChildConfig(t *testing.T) { t.Parallel() cfg := ` include { path = find_in_parent_folders("root.hcl") } terraform { source = path_relative_to_include() } ` l := createLogger() absConfigPath, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", config.DefaultTerragruntConfigPath)) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, absConfigPath) pctx.MaxFoldersToCheck = 5 terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, pctx.TerragruntConfigPath, cfg, nil) if err != nil { t.Fatal(err) } assert.Equal(t, "child", *terragruntConfig.Terraform.Source) } func TestModuleDependenciesMerge(t *testing.T) { t.Parallel() testCases := []struct { name string target []string source []string expected []string }{ { "MergeNil", []string{"../vpc", "../sql"}, nil, []string{"../vpc", "../sql"}, }, { "MergeOne", []string{"../vpc", "../sql"}, []string{"../services"}, []string{"../vpc", "../sql", "../services"}, }, { "MergeMany", []string{"../vpc", "../sql"}, []string{"../services", "../groups"}, []string{"../vpc", "../sql", "../services", "../groups"}, }, { "MergeEmpty", []string{"../vpc", "../sql"}, []string{}, []string{"../vpc", "../sql"}, }, { "MergeOneExisting", []string{"../vpc", "../sql"}, []string{"../vpc"}, []string{"../vpc", "../sql"}, }, { "MergeAllExisting", []string{"../vpc", "../sql"}, []string{"../vpc", "../sql"}, []string{"../vpc", "../sql"}, }, { "MergeSomeExisting", []string{"../vpc", "../sql"}, []string{"../vpc", "../services"}, []string{"../vpc", "../sql", "../services"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() target := &config.ModuleDependencies{Paths: tc.target} var source *config.ModuleDependencies = nil if tc.source != nil { source = &config.ModuleDependencies{Paths: tc.source} } target.Merge(source) assert.Equal(t, tc.expected, target.Paths) }) } } func ptr(str string) *string { return &str } // Run a benchmark on ReadTerragruntConfig for all fixtures possible. // This should reveal regressions on execution time due to new, changed or removed features. func BenchmarkReadTerragruntConfig(b *testing.B) { // Setup b.StopTimer() testDir := "../test" fixtureDirs := []struct { description string workingDir string usePartialParseCache bool }{ {"PartialParseBenchmarkRegressionCaching", "regressions/benchmark-parsing/production/deployment-group-1/webserver/terragrunt.hcl", true}, {"PartialParseBenchmarkRegressionNoCache", "regressions/benchmark-parsing/production/deployment-group-1/webserver/terragrunt.hcl", false}, {"PartialParseBenchmarkRegressionIncludesCaching", "regressions/benchmark-parsing-includes/production/deployment-group-1/webserver/terragrunt.hcl", true}, {"PartialParseBenchmarkRegressionIncludesNoCache", "regressions/benchmark-parsing-includes/production/deployment-group-1/webserver/terragrunt.hcl", false}, } // Run benchmarks for _, fixture := range fixtureDirs { b.Run(fixture.description, func(b *testing.B) { workingDir, err := filepath.Abs(filepath.Join(testDir, fixture.workingDir)) require.NoError(b, err) require.NoError(b, err) l := createLogger() _, pctx := newTestParsingContext(b, workingDir) pctx.UsePartialParseConfigCache = fixture.usePartialParseCache b.ResetTimer() b.StartTimer() actual, err := config.ReadTerragruntConfig(b.Context(), l, pctx, config.DefaultParserOptions(l, pctx.StrictControls)) b.StopTimer() require.NoError(b, err) assert.NotNil(b, actual) }) } } func TestBestEffortParseConfigString(t *testing.T) { t.Parallel() tc := []struct { expectedConfig *config.TerragruntConfig name string cfg string expectError bool }{ { name: "Simple", cfg: `locals { simple = "value" requires_auth = run_cmd("bash", "-c", "exit 1") // intentional error } `, expectError: true, expectedConfig: &config.TerragruntConfig{ Locals: map[string]any{ "simple": "value", }, GenerateConfigs: map[string]codegen.GenerateConfig{}, ProcessedIncludes: config.IncludeConfigsMap{}, FieldsMetadata: map[string]map[string]any{ "locals-simple": { "found_in_file": "terragrunt.hcl", }, }, }, }, { name: "Locals referencing each other", cfg: `locals { reference = local.simple simple = "value" } `, expectError: false, expectedConfig: &config.TerragruntConfig{ Locals: map[string]any{ "reference": "value", "simple": "value", }, GenerateConfigs: map[string]codegen.GenerateConfig{}, ProcessedIncludes: config.IncludeConfigsMap{}, FieldsMetadata: map[string]map[string]any{ "locals-reference": { "found_in_file": "terragrunt.hcl", }, "locals-simple": { "found_in_file": "terragrunt.hcl", }, }, }, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { t.Parallel() l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, tt.cfg, nil) if tt.expectError { require.Error(t, err) } else { require.NoError(t, err) } assert.Equal(t, tt.expectedConfig, terragruntConfig) }) } } func TestParseConfigWithMissingIfExists(t *testing.T) { t.Parallel() cfg := `generate "test" { path = "test.tf" contents = "foo" }` l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) errStr := err.Error() hasIfExistsError := strings.Contains(errStr, "if_exists") hasGenerateError := strings.Contains(errStr, "generate") || strings.Contains(errStr, "Missing required argument") assert.True(t, hasIfExistsError || hasGenerateError, "Error message should mention missing if_exists attribute or generate block. Got: %s", errStr) assert.NotNil(t, terragruntConfig) } func TestBestEffortParseConfigStringWDependency(t *testing.T) { t.Parallel() depCfg := `locals { simple = "value" fail = run_cmd("bash", "-c", "exit 1") // intentional error }` cfg := `locals { simple = "value" fail = run_cmd("bash", "-c", "exit 1") // intentional error } dependency "dep" { config_path = "../dep" }` tmpDir := helpers.TmpDirWOSymlinks(t) depPath := filepath.Join(tmpDir, "dep") require.NoError(t, os.MkdirAll(depPath, 0755)) depCfgPath := filepath.Join(depPath, config.DefaultTerragruntConfigPath) require.NoError(t, os.WriteFile(depCfgPath, []byte(depCfg), 0644)) unitPath := filepath.Join(tmpDir, "unit") require.NoError(t, os.MkdirAll(unitPath, 0755)) unitCfgPath := filepath.Join(unitPath, config.DefaultTerragruntConfigPath) require.NoError(t, os.WriteFile(unitCfgPath, []byte(cfg), 0644)) l := createLogger() ctx, pctx := newTestParsingContext(t, "test-time-mock") pctx.WorkingDir = unitPath terragruntConfig, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.Error(t, err) assert.Equal(t, &config.TerragruntConfig{ Locals: map[string]any{ "simple": "value", }, GenerateConfigs: map[string]codegen.GenerateConfig{}, ProcessedIncludes: config.IncludeConfigsMap{}, FieldsMetadata: map[string]map[string]any{ "dependency-dep": { "found_in_file": "terragrunt.hcl", }, "locals-simple": { "found_in_file": "terragrunt.hcl", }, }, TerragruntDependencies: config.Dependencies{ config.Dependency{ Name: "dep", ConfigPath: cty.StringVal("../dep"), }, }, }, terragruntConfig) } func TestWriteTo(t *testing.T) { t.Parallel() cfg := ` locals { string = "value" bool = true number = 123 list = ["a", "b", "c"] } terraform { source = "git::git@github.com:org/repo.git//modules/test?ref=v0.1.0" extra_arguments "secrets" { commands = ["plan", "apply"] arguments = ["-var-file=secrets.tfvars"] required_var_files = ["common.tfvars"] optional_var_files = ["optional.tfvars"] env_vars = { TEST_VAR = "value" } } before_hook "before" { commands = ["plan", "apply"] execute = ["echo", "before"] working_dir = "before_dir" } after_hook "after" { commands = ["plan", "apply"] execute = ["echo", "after"] working_dir = "after_dir" } error_hook "error" { commands = ["plan", "apply"] execute = ["echo", "error"] on_errors = [ ".*Error.*", ".*Exception.*" ] working_dir = "error_dir" } } engine { source = "github.com/gruntwork-io/terragrunt" version = "v0.1.0" type = "rpc" meta = { key = "value" } } exclude { exclude_dependencies = true actions = ["init", "plan"] if = true } errors { retry "test_retry" { max_attempts = 3 sleep_interval_sec = 5 retryable_errors = [ ".*Error.*", ".*Exception.*" ] } ignore "test_ignore" { ignorable_errors = [ ".*Warning.*", ".*Deprecated.*" ] message = "Ignoring warning messages" signals = { key = "value" } } } // The catalog block won't actually show up when using // ParseConfigString. It probably should, but that's not // a problem for this test. // // catalog { // default_template = "default.hcl" // urls = [ // "github.com/org/repo//templates/template1.hcl", // "github.com/org/repo//templates/template2.hcl" // ] // } remote_state { backend = "s3" disable_init = true disable_dependency_optimization = true config = { bucket = "my-bucket" key = "terraform.tfstate" region = "us-east-1" } } // These aren't worth testing because they require filesystem operations // as currently implemented, and we don't want to do that in this test. // // dependencies { // paths = ["../vpc", "../database"] // } // dependency "vpc" { // config_path = "../vpc" // skip_outputs = true // mock_outputs = { // vpc_id = "mock-vpc-id" // } // } generate "provider" { path = "provider.tf" if_exists = "overwrite" contents = < 0 { assert.Equal(t, terragruntConfig.Errors.Retry[0].Label, rereadConfig.Errors.Retry[0].Label) assert.Equal(t, terragruntConfig.Errors.Retry[0].MaxAttempts, rereadConfig.Errors.Retry[0].MaxAttempts) assert.Equal(t, terragruntConfig.Errors.Retry[0].SleepIntervalSec, rereadConfig.Errors.Retry[0].SleepIntervalSec) assert.Equal(t, terragruntConfig.Errors.Retry[0].RetryableErrors, rereadConfig.Errors.Retry[0].RetryableErrors) } assert.Len(t, terragruntConfig.Errors.Ignore, len(rereadConfig.Errors.Ignore)) if len(terragruntConfig.Errors.Ignore) > 0 { assert.Equal(t, terragruntConfig.Errors.Ignore[0].Label, rereadConfig.Errors.Ignore[0].Label) assert.Equal(t, terragruntConfig.Errors.Ignore[0].IgnorableErrors, rereadConfig.Errors.Ignore[0].IgnorableErrors) assert.Equal(t, terragruntConfig.Errors.Ignore[0].Message, rereadConfig.Errors.Ignore[0].Message) assert.Equal(t, terragruntConfig.Errors.Ignore[0].Signals, rereadConfig.Errors.Ignore[0].Signals) } // The catalog block won't actually show up when using // ParseConfigString. It probably should, but that's not // a problem for this test. // // assert.Equal(t, terragruntConfig.Catalog.DefaultTemplate, rereadConfig.Catalog.DefaultTemplate) // assert.Equal(t, terragruntConfig.Catalog.URLs, rereadConfig.Catalog.URLs) assert.Equal(t, terragruntConfig.RemoteState.BackendName, rereadConfig.RemoteState.BackendName) assert.Equal(t, terragruntConfig.RemoteState.DisableInit, rereadConfig.RemoteState.DisableInit) assert.Equal(t, terragruntConfig.RemoteState.DisableDependencyOptimization, rereadConfig.RemoteState.DisableDependencyOptimization) assert.Equal(t, terragruntConfig.RemoteState.BackendConfig, rereadConfig.RemoteState.BackendConfig) // We don't test dependencies here because they require filesystem operations. // assert.Equal(t, terragruntConfig.Dependencies.Paths, rereadConfig.Dependencies.Paths) // assert.Equal(t, terragruntConfig.TerragruntDependencies, rereadConfig.TerragruntDependencies) assert.Equal(t, terragruntConfig.GenerateConfigs, rereadConfig.GenerateConfigs) assert.Equal(t, terragruntConfig.FeatureFlags, rereadConfig.FeatureFlags) assert.Equal(t, terragruntConfig.TerraformBinary, rereadConfig.TerraformBinary) assert.Equal(t, terragruntConfig.TerraformVersionConstraint, rereadConfig.TerraformVersionConstraint) assert.Equal(t, terragruntConfig.TerragruntVersionConstraint, rereadConfig.TerragruntVersionConstraint) assert.Equal(t, terragruntConfig.DownloadDir, rereadConfig.DownloadDir) assert.Equal(t, terragruntConfig.PreventDestroy, rereadConfig.PreventDestroy) assert.Equal(t, terragruntConfig.IamRole, rereadConfig.IamRole) assert.Equal(t, terragruntConfig.IamAssumeRoleDuration, rereadConfig.IamAssumeRoleDuration) assert.Equal(t, terragruntConfig.IamAssumeRoleSessionName, rereadConfig.IamAssumeRoleSessionName) assert.Equal(t, terragruntConfig.Inputs, rereadConfig.Inputs) } ================================================ FILE: pkg/config/context.go ================================================ package config import ( "context" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) type configKey byte const ( HclCacheContextKey configKey = iota TerragruntConfigCacheContextKey configKey = iota RunCmdCacheContextKey configKey = iota DependencyOutputCacheContextKey configKey = iota JSONOutputCacheContextKey configKey = iota OutputLocksContextKey configKey = iota SopsCacheContextKey configKey = iota hclCacheName = "hclCache" configCacheName = "configCache" runCmdCacheName = "runCmdCache" dependencyOutputCacheName = "dependencyOutputCache" jsonOutputCacheName = "jsonOutputCache" sopsCacheName = "sopsCache" ) // WithConfigValues add to context default values for configuration. func WithConfigValues(ctx context.Context) context.Context { ctx = context.WithValue(ctx, HclCacheContextKey, cache.NewCache[*hclparse.File](hclCacheName)) ctx = context.WithValue(ctx, TerragruntConfigCacheContextKey, cache.NewCache[*TerragruntConfig](configCacheName)) ctx = context.WithValue(ctx, RunCmdCacheContextKey, cache.NewCache[*RunCmdCacheEntry](runCmdCacheName)) ctx = context.WithValue(ctx, DependencyOutputCacheContextKey, cache.NewCache[*dependencyOutputCache](dependencyOutputCacheName)) ctx = context.WithValue(ctx, JSONOutputCacheContextKey, cache.NewCache[[]byte](jsonOutputCacheName)) ctx = context.WithValue(ctx, OutputLocksContextKey, util.NewKeyLocks()) ctx = context.WithValue(ctx, SopsCacheContextKey, cache.NewCache[string](sopsCacheName)) return ctx } ================================================ FILE: pkg/config/cty_helpers.go ================================================ //nolint:dupl package config import ( "context" "encoding/json" "dario.cat/mergo" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ctyjson "github.com/zclconf/go-cty/cty/json" "maps" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/errors" ) // Create a cty Function that takes as input parameters a slice of strings (var args, so this slice could be of any // length) and returns as output a string. The implementation of the function calls the given toWrap function, passing // it the input parameters string slice as well as the given include and terragruntOptions. func wrapStringSliceToStringAsFuncImpl( ctx context.Context, pctx *ParsingContext, l log.Logger, toWrap func(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error), ) function.Function { return function.New(&function.Spec{ VarParam: &function.Parameter{Type: cty.String}, Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { params, err := ctySliceToStringSlice(args) if err != nil { return cty.StringVal(""), err } out, err := toWrap(ctx, pctx, l, params) if err != nil { return cty.StringVal(""), err } return cty.StringVal(out), nil }, }) } func wrapStringSliceToNumberAsFuncImpl( ctx context.Context, pctx *ParsingContext, l log.Logger, toWrap func(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (int64, error), ) function.Function { return function.New(&function.Spec{ VarParam: &function.Parameter{Type: cty.String}, Type: function.StaticReturnType(cty.Number), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { params, err := ctySliceToStringSlice(args) if err != nil { return cty.NumberIntVal(0), err } out, err := toWrap(ctx, pctx, l, params) if err != nil { return cty.NumberIntVal(0), err } return cty.NumberIntVal(out), nil }, }) } func wrapStringSliceToBoolAsFuncImpl( ctx context.Context, pctx *ParsingContext, toWrap func(ctx context.Context, pctx *ParsingContext, params []string) (bool, error), ) function.Function { return function.New(&function.Spec{ VarParam: &function.Parameter{Type: cty.String}, Type: function.StaticReturnType(cty.Bool), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { params, err := ctySliceToStringSlice(args) if err != nil { return cty.BoolVal(false), err } out, err := toWrap(ctx, pctx, params) if err != nil { return cty.BoolVal(false), err } return cty.BoolVal(out), nil }, }) } // Create a cty Function that takes no input parameters and returns as output a string. The implementation of the // function calls the given toWrap function, passing it the given include and terragruntOptions. func wrapVoidToStringAsFuncImpl( ctx context.Context, pctx *ParsingContext, l log.Logger, toWrap func(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error), ) function.Function { return function.New(&function.Spec{ Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { out, err := toWrap(ctx, pctx, l) if err != nil { return cty.StringVal(""), err } return cty.StringVal(out), nil }, }) } // Create a cty Function that takes no input parameters and returns as output an empty string. func wrapVoidToEmptyStringAsFuncImpl() function.Function { return function.New(&function.Spec{ Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { return cty.StringVal(""), nil }, }) } // Create a cty Function that takes no input parameters and returns as output a string slice. The implementation of the // function calls the given toWrap function, passing it the given include and terragruntOptions. func wrapVoidToStringSliceAsFuncImpl( ctx context.Context, pctx *ParsingContext, l log.Logger, toWrap func(ctx context.Context, pctx *ParsingContext, l log.Logger) ([]string, error), ) function.Function { return function.New(&function.Spec{ Type: function.StaticReturnType(cty.List(cty.String)), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { outVals, err := toWrap(ctx, pctx, l) if err != nil || len(outVals) == 0 { return cty.ListValEmpty(cty.String), err } outCtyVals := []cty.Value{} for _, val := range outVals { outCtyVals = append(outCtyVals, cty.StringVal(val)) } return cty.ListVal(outCtyVals), nil }, }) } // Create a cty Function that takes no input parameters and returns as output a string slice. The implementation of the // function returns the given string slice. func wrapStaticValueToStringSliceAsFuncImpl(out []string) function.Function { return function.New(&function.Spec{ Type: function.StaticReturnType(cty.List(cty.String)), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { outVals := make([]cty.Value, 0, len(out)) for _, val := range out { outVals = append(outVals, cty.StringVal(val)) } return cty.ListVal(outVals), nil }, }) } // Convert the slice of cty values to a slice of strings. If any of the values in the given slice is not a string, // return an error. func ctySliceToStringSlice(args []cty.Value) ([]string, error) { var out = make([]string, 0, len(args)) for _, arg := range args { if arg.Type() != cty.String { return nil, errors.New(InvalidParameterTypeError{Expected: "string", Actual: arg.Type().FriendlyName()}) } out = append(out, arg.AsString()) } return out, nil } // shallowMergeCtyMaps performs a shallow merge of two cty value objects. func shallowMergeCtyMaps(target cty.Value, source cty.Value) (*cty.Value, error) { outMap, err := ctyhelper.ParseCtyValueToMap(target) if err != nil { return nil, err } SourceMap, err := ctyhelper.ParseCtyValueToMap(source) if err != nil { return nil, err } for key, sourceValue := range SourceMap { if _, ok := outMap[key]; !ok { outMap[key] = sourceValue } } outCty, err := convertToCtyWithJSON(outMap) if err != nil { return nil, err } return &outCty, nil } func deepMergeCtyMaps(target cty.Value, source cty.Value) (*cty.Value, error) { return deepMergeCtyMapsMapOnly(target, source, mergo.WithAppendSlice) } // deepMergeCtyMapsMapOnly implements a deep merge of two cty value objects. We can't directly merge two cty.Value objects, so // we cheat by using map[string]any as an intermediary. Note that this assumes the provided cty value objects // are already maps or objects in HCL land. func deepMergeCtyMapsMapOnly(target cty.Value, source cty.Value, opts ...func(*mergo.Config)) (*cty.Value, error) { outMap := make(map[string]any) targetMap, err := ctyhelper.ParseCtyValueToMap(target) if err != nil { return nil, err } sourceMap, err := ctyhelper.ParseCtyValueToMap(source) if err != nil { return nil, err } maps.Copy(outMap, targetMap) if err := mergo.Merge(&outMap, sourceMap, append(opts, mergo.WithOverride)...); err != nil { return nil, err } outCty, err := convertToCtyWithJSON(outMap) if err != nil { return nil, err } return &outCty, nil } // ConvertValuesMapToCtyVal takes a map of name - cty.Value pairs and converts to a single cty.Value object. func ConvertValuesMapToCtyVal(valMap map[string]cty.Value) (cty.Value, error) { if len(valMap) == 0 { // Return an empty object instead of NilVal for empty maps. return cty.EmptyObjectVal, nil } // Use cty.ObjectVal directly instead of gocty.ToCtyValue to preserve marks (like sensitive()) return cty.ObjectVal(valMap), nil } // generateTypeFromValuesMap takes a values map and returns an object type that has the same number of fields, but // bound to each type of the underlying evaluated expression. This is the only way the HCL decoder will be happy, as // object type is the only map type that allows different types for each attribute (cty.Map requires all attributes to // have the same type. func generateTypeFromValuesMap(valMap map[string]cty.Value) cty.Type { outType := map[string]cty.Type{} for k, v := range valMap { outType[k] = v.Type() } return cty.Object(outType) } // includeMapAsCtyVal converts the include map into a cty.Value struct that can be exposed to the child config. For // backward compatibility, this function will return the included config object if the config only defines a single bare // include block that is exposed. // NOTE: When evaluated in a partial parse ctx, only the partially parsed ctx is available in the expose. This // ensures that we can parse the child config without having access to dependencies when constructing the dependency // graph. func includeMapAsCtyVal(ctx context.Context, pctx *ParsingContext, l log.Logger) (cty.Value, error) { bareInclude, hasBareInclude := pctx.TrackInclude.CurrentMap[bareIncludeKey] if len(pctx.TrackInclude.CurrentMap) == 1 && hasBareInclude { l.Debug("Detected single bare include block - exposing as top level") return includeConfigAsCtyVal(ctx, pctx, l, bareInclude) } exposedIncludeMap := map[string]cty.Value{} for key, included := range pctx.TrackInclude.CurrentMap { parsedIncludedCty, err := includeConfigAsCtyVal(ctx, pctx, l, included) if err != nil { return cty.NilVal, err } if parsedIncludedCty != cty.NilVal { l.Debugf("Exposing include block '%s'", key) exposedIncludeMap[key] = parsedIncludedCty } } return ConvertValuesMapToCtyVal(exposedIncludeMap) } // includeConfigAsCtyVal returns the parsed include block as a cty.Value object if expose is true. Otherwise, return // the nil representation of cty.Value. func includeConfigAsCtyVal(ctx context.Context, pctx *ParsingContext, l log.Logger, includeConfig IncludeConfig) (cty.Value, error) { pctx = pctx.WithTrackInclude(nil) if includeConfig.GetExpose() { parsedIncluded, err := parseIncludedConfig(ctx, pctx, l, &includeConfig) if err != nil { return cty.NilVal, err } parsedIncludedCty, err := TerragruntConfigAsCty(parsedIncluded) if err != nil { return cty.NilVal, err } return parsedIncludedCty, nil } return cty.NilVal, nil } // CtyToStruct converts a cty.Value to a go struct. func CtyToStruct(ctyValue cty.Value, target any) error { jsonBytes, err := ctyjson.Marshal(ctyValue, ctyValue.Type()) if err != nil { return errors.New(err) } if err := json.Unmarshal(jsonBytes, target); err != nil { return errors.New(err) } return nil } // CtyValueAsString converts a cty.Value to a string. func CtyValueAsString(val cty.Value) (string, error) { jsonBytes, err := ctyjson.Marshal(val, val.Type()) if err != nil { return "", err } return string(jsonBytes), nil } // GetValueString returns the string representation of a cty.Value. // If the value is of type cty.String, it returns the raw string value directly. // Otherwise, it falls back to converting the value to a JSON-formatted string // using the CtyValueAsString helper function. // // Returns an error if the conversion fails. func GetValueString(value cty.Value) (string, error) { if value.Type() == cty.String { return value.AsString(), nil } return CtyValueAsString(value) } // IsComplexType checks if a value is a complex data type that can't be used with raw output. func IsComplexType(value cty.Value) bool { return value.Type().IsObjectType() || value.Type().IsMapType() || value.Type().IsListType() || value.Type().IsTupleType() || value.Type().IsSetType() } // GetFirstKey returns the first key from a map. // This is a helper for maps that are known to have exactly one element. func GetFirstKey(m map[string]cty.Value) string { for k := range m { return k } return "" } ================================================ FILE: pkg/config/dependency.go ================================================ package config import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "slices" "strings" "sync" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gruntwork-io/terragrunt/internal/awshelper" "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/pkg/log" s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3" "github.com/hashicorp/go-getter" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ctyjson "github.com/zclconf/go-cty/cty/json" "golang.org/x/sync/errgroup" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/amazonsts" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) const ( renderJSONCommand = "render-json" renderCommand = "render" ) type Dependencies []Dependency // Struct to hold the decoded dependency blocks. type dependencyOutputCache struct { Enabled *bool Inputs cty.Value } type Dependency struct { ConfigPath cty.Value `hcl:"config_path,attr" cty:"config_path"` Enabled *bool `hcl:"enabled,attr" cty:"enabled"` SkipOutputs *bool `hcl:"skip_outputs,attr" cty:"skip"` MockOutputs *cty.Value `hcl:"mock_outputs,attr" cty:"mock_outputs"` MockOutputsAllowedTerraformCommands *[]string `hcl:"mock_outputs_allowed_terraform_commands,attr" cty:"mock_outputs_allowed_terraform_commands"` // MockOutputsMergeWithState is deprecated. Use MockOutputsMergeStrategyWithState MockOutputsMergeWithState *bool `hcl:"mock_outputs_merge_with_state,attr" cty:"mock_outputs_merge_with_state"` MockOutputsMergeStrategyWithState *MergeStrategyType `hcl:"mock_outputs_merge_strategy_with_state" cty:"mock_outputs_merge_strategy_with_state"` // Used to store the rendered outputs for use when the config is imported or read with `read_terragrunt_config` RenderedOutputs *cty.Value `cty:"outputs"` Inputs *cty.Value `cty:"inputs"` Name string `hcl:",label" cty:"name"` } // DeepMerge will deep merge two Dependency configs, updating the target. Deep merge for Dependency configs is defined // as follows: // - For simple attributes (bools and strings), the source will override the target. // - For MockOutputs, the two maps will be deeply merged together. This means that maps are recursively merged, while // lists are concatenated together. // - For MockOutputsAllowedTerraformCommands, the source will be concatenated to the target. // // Note that RenderedOutputs is ignored in the deep merge operation. func (dep *Dependency) DeepMerge(sourceDepConfig *Dependency) error { if sourceDepConfig.ConfigPath.AsString() != "" { dep.ConfigPath = sourceDepConfig.ConfigPath } if sourceDepConfig.Enabled != nil { dep.Enabled = sourceDepConfig.Enabled } if sourceDepConfig.SkipOutputs != nil { dep.SkipOutputs = sourceDepConfig.SkipOutputs } if sourceDepConfig.MockOutputs != nil { if dep.MockOutputs == nil { dep.MockOutputs = sourceDepConfig.MockOutputs } else { newMockOutputs, err := deepMergeCtyMaps(*dep.MockOutputs, *sourceDepConfig.MockOutputs) if err != nil { return err } dep.MockOutputs = newMockOutputs } } if sourceDepConfig.MockOutputsAllowedTerraformCommands != nil { if dep.MockOutputsAllowedTerraformCommands == nil { dep.MockOutputsAllowedTerraformCommands = sourceDepConfig.MockOutputsAllowedTerraformCommands } else { mergedCmds := append(*dep.MockOutputsAllowedTerraformCommands, *sourceDepConfig.MockOutputsAllowedTerraformCommands...) dep.MockOutputsAllowedTerraformCommands = &mergedCmds } } return nil } // getMockOutputsMergeStrategy returns the MergeStrategyType following the deprecation of mock_outputs_merge_with_state // - If mock_outputs_merge_strategy_with_state is not null. The value of mock_outputs_merge_strategy_with_state will be returned // - If mock_outputs_merge_strategy_with_state is null and mock_outputs_merge_with_state is not null: // - mock_outputs_merge_with_state being true returns ShallowMerge // - mock_outputs_merge_with_state being false returns NoMerge func (dep *Dependency) getMockOutputsMergeStrategy() MergeStrategyType { if dep.MockOutputsMergeStrategyWithState == nil { if dep.MockOutputsMergeWithState != nil && (*dep.MockOutputsMergeWithState) { return ShallowMerge } else { return NoMerge } } return *dep.MockOutputsMergeStrategyWithState } // Given a dependency config, we should only attempt to get the outputs if SkipOutputs is nil or false func (dep *Dependency) shouldGetOutputs(ctx *ParsingContext) bool { return !ctx.SkipOutput && dep.isEnabled() && (dep.SkipOutputs == nil || !*dep.SkipOutputs) } // isEnabled returns true if the dependency is enabled func (dep *Dependency) isEnabled() bool { if dep.Enabled == nil { return true } return *dep.Enabled } // isDisabled returns true if the dependency is disabled func (dep *Dependency) isDisabled() bool { return !dep.isEnabled() } // Given a dependency config, we should only attempt to merge mocks outputs with the outputs if MockOutputsMergeWithState is not nil or true func (dep *Dependency) shouldMergeMockOutputsWithState(ctx *ParsingContext) bool { allowedCommand := dep.MockOutputsAllowedTerraformCommands == nil || len(*dep.MockOutputsAllowedTerraformCommands) == 0 || slices.Contains(*dep.MockOutputsAllowedTerraformCommands, ctx.OriginalTerraformCommand) return allowedCommand && dep.getMockOutputsMergeStrategy() != NoMerge } func (dep *Dependency) setRenderedOutputs(ctx context.Context, pctx *ParsingContext, l log.Logger) error { if dep == nil { return nil } if dep.shouldGetOutputs(pctx) || dep.shouldReturnMockOutputs(pctx) { outputVal, err := getTerragruntOutputIfAppliedElseConfiguredDefault(ctx, pctx, l, dep) if err != nil { return err } dep.RenderedOutputs = outputVal } return nil } // outputLocksFromContext retrieves the KeyLocks from the context for synchronizing output retrieval. // If not present in context, returns a new KeyLocks instance. func outputLocksFromContext(ctx context.Context) *util.KeyLocks { if val, ok := ctx.Value(OutputLocksContextKey).(*util.KeyLocks); ok && val != nil { return val } return util.NewKeyLocks() } // Decode the dependency blocks from the file, and then retrieve all the outputs from the remote state. Then encode the // resulting map as a cty.Value object. // TODO: In the future, consider allowing importing dependency blocks from included config // NOTE FOR MAINTAINER: When implementing importation of other config blocks (e.g referencing inputs), carefully // // consider whether or not the implementation of the cyclic dependency detection still makes sense. func decodeAndRetrieveOutputs(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File) (*cty.Value, error) { evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } decodedDependency := TerragruntDependency{} if err := file.Decode(&decodedDependency, evalParsingContext); err != nil { return nil, err } // In normal operation, if a dependency block does not have a `config_path` attribute, decoding returns an error since this attribute is required, but the `hclvalidate` command suppresses decoding errors and this causes a cycle between modules, so we need to filter out dependencies without a defined `config_path`. decodedDependency.Dependencies = decodedDependency.Dependencies.FilteredWithoutConfigPath() // Validate that dependency config_path is not an empty string. // Skip null/unknown values and non-strings (which can appear during partial decode or hclvalidate). for _, dep := range decodedDependency.Dependencies { if dep.isDisabled() { continue } if !IsValidConfigPath(dep.ConfigPath) { return nil, errors.New(DependencyInvalidConfigPathError{DependencyName: dep.Name}) } } if err := checkForDependencyBlockCycles(ctx, pctx, l, pctx.TerragruntConfigPath, decodedDependency); err != nil { return nil, err } updatedDependencies, err := decodeDependencies(ctx, pctx, l, decodedDependency) if err != nil { return nil, err } decodedDependency = *updatedDependencies // Merge in included dependencies if pctx.TrackInclude != nil { mergedDecodedDependency, err := handleIncludeForDependency(ctx, pctx, l, decodedDependency) if err != nil { return nil, err } decodedDependency = *mergedDecodedDependency } // Extract dependency names for tracing dependencyNames := make([]string, 0, len(decodedDependency.Dependencies)) for _, dep := range decodedDependency.Dependencies { dependencyNames = append(dependencyNames, dep.Name) } var result *cty.Value err = TraceParseDependencies(ctx, file.ConfigPath, pctx.SkipOutputsResolution, len(decodedDependency.Dependencies), dependencyNames, func(ctx context.Context) error { var depErr error result, depErr = dependencyBlocksToCtyValue(ctx, pctx, l, file.ConfigPath, decodedDependency.Dependencies) return depErr }) return result, err } // decodeDependencies decode dependencies and fetch inputs func decodeDependencies(ctx context.Context, pctx *ParsingContext, l log.Logger, decodedDependency TerragruntDependency) (*TerragruntDependency, error) { updatedDependencies := TerragruntDependency{} depCache := cache.ContextCache[*dependencyOutputCache](ctx, DependencyOutputCacheContextKey) for _, dep := range decodedDependency.Dependencies { if !dep.isEnabled() { updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep) continue } if !IsValidConfigPath(dep.ConfigPath) { return &updatedDependencies, errors.New(DependencyInvalidConfigPathError{DependencyName: dep.Name}) } depPath := getCleanedTargetConfigPath(dep.ConfigPath.AsString(), pctx.TerragruntConfigPath) if !util.FileExists(depPath) { updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep) continue } cacheKey := filepath.Join(pctx.WorkingDir, depPath) // Cache hit - reuse cached values if cachedDependency, found := depCache.Get(ctx, cacheKey); found { dep.Enabled = cachedDependency.Enabled dep.Inputs = &cachedDependency.Inputs updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep) continue } // Cache miss - parse and cache if !pctx.SkipOutputsResolution { l.Debugf("Reading Terragrunt config file at %s", util.RelPathForLog(pctx.RootWorkingDir, depPath, pctx.Writers.LogShowAbsPaths)) } _, depCtx, err := pctx.WithConfigPath(l, depPath) if err != nil { return nil, err } depCtx.DownloadDir = filepath.Join(filepath.Dir(depPath), util.TerragruntCacheDir) if depCtx.IAMRoleOptions != depCtx.OriginalIAMRoleOptions { depCtx.IAMRoleOptions = iam.RoleOptions{} } depCtx = depCtx.WithDecodeList(TerragruntFlags).WithDiagnosticsSuppressed(l) depConfig, err := PartialParseConfigFile(ctx, depCtx, l, depPath, nil) if err != nil { l.Warnf("Error reading partial config for dependency %s at %s: %v", dep.Name, depPath, err) updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep) continue } inputsCty, err := convertToCtyWithJSON(depConfig.Inputs) if err != nil { return nil, errors.Errorf("failed to convert inputs for dependency %q: %w", dep.Name, err) } cachedValue := dependencyOutputCache{ Enabled: dep.Enabled, Inputs: inputsCty, } depCache.Put(ctx, cacheKey, &cachedValue) dep.Inputs = &inputsCty updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep) } return &updatedDependencies, nil } // Convert the list of parsed Dependency blocks into a list of module dependencies. Each output block should // become a dependency of the current config, since that module has to be applied before we can read the output. func dependencyBlocksToModuleDependencies(l log.Logger, decodedDependencyBlocks []Dependency) *ModuleDependencies { if len(decodedDependencyBlocks) == 0 { return nil } paths := []string{} for _, decodedDependencyBlock := range decodedDependencyBlocks { // skip dependency if is not enabled if !decodedDependencyBlock.isEnabled() { continue } // Skip if ConfigPath is not a known string value (can happen during discovery phase) if decodedDependencyBlock.ConfigPath.IsNull() || !decodedDependencyBlock.ConfigPath.IsWhollyKnown() || !decodedDependencyBlock.ConfigPath.Type().Equals(cty.String) { l.Debugf("Skipping dependency %q: ConfigPath is not a valid known string value", decodedDependencyBlock.Name) continue } paths = append(paths, decodedDependencyBlock.ConfigPath.AsString()) } return &ModuleDependencies{Paths: paths} } // Check for cyclic dependency blocks to avoid infinite `terragrunt output` loops. To avoid reparsing the config, we // kickstart the initial loop using what we already decoded. func checkForDependencyBlockCycles(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string, decodedDependency TerragruntDependency) error { visitedPaths := []string{} currentTraversalPaths := []string{configPath} for _, dependency := range decodedDependency.Dependencies { if dependency.isDisabled() { continue } if !IsValidConfigPath(dependency.ConfigPath) { return errors.New(DependencyInvalidConfigPathError{DependencyName: dependency.Name}) } dependencyPath := getCleanedTargetConfigPath(dependency.ConfigPath.AsString(), configPath) l, dependencyContext, err := pctx.WithConfigPath(l, dependencyPath) if err != nil { return err } if err := checkForDependencyBlockCyclesUsingDFS(ctx, dependencyContext, l, dependencyPath, &visitedPaths, ¤tTraversalPaths); err != nil { return err } } return nil } // Helper function for checkForDependencyBlockCycles. // // Same implementation as configstack/graph.go:checkForCyclesUsingDepthFirstSearch, except walks the graph of // dependencies by `dependency` blocks (which make explicit `terragrunt output` calls) instead of explicit dependencies. func checkForDependencyBlockCyclesUsingDFS( ctx context.Context, pctx *ParsingContext, l log.Logger, dependencyPath string, visitedPaths *[]string, currentTraversalPaths *[]string, ) error { if slices.Contains(*visitedPaths, dependencyPath) { return nil } if slices.Contains(*currentTraversalPaths, dependencyPath) { return errors.New(DependencyCycleError(append(*currentTraversalPaths, dependencyPath))) } *currentTraversalPaths = append(*currentTraversalPaths, dependencyPath) dependencyPaths, err := getDependencyBlockConfigPathsByFilepath(ctx, pctx, l, dependencyPath) if err != nil { return err } for _, dependency := range dependencyPaths { dependencyPath := getCleanedTargetConfigPath(dependency, dependencyPath) l, dependencyContext, err := pctx.WithConfigPath(l, dependencyPath) if err != nil { return err } if err := checkForDependencyBlockCyclesUsingDFS(ctx, dependencyContext, l, dependencyPath, visitedPaths, currentTraversalPaths); err != nil { return err } } *visitedPaths = append(*visitedPaths, dependencyPath) *currentTraversalPaths = slices.DeleteFunc(*currentTraversalPaths, func(path string) bool { return path == dependencyPath }) return nil } // Given the config path, return the list of config paths that are specified as dependency blocks in the config func getDependencyBlockConfigPathsByFilepath(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string) ([]string, error) { // This will automatically parse everything needed to parse the dependency block configs, and load them as // TerragruntConfig.Dependencies. Note that since we aren't passing in `DependenciesBlock` to the // PartialDecodeSectionType list, the Dependencies attribute will not include any dependencies specified via the // dependencies block. tgConfig, err := PartialParseConfigFile(ctx, pctx.WithDecodeList(DependencyBlock).WithDiagnosticsSuppressed(l), l, configPath, nil) if err != nil { return nil, err } if tgConfig.Dependencies == nil { return []string{}, nil } return tgConfig.Dependencies.Paths, nil } // Encode the list of dependency blocks into a single cty.Value object that maps the dependency block name to the // encoded dependency mapping. The encoded dependency mapping should have the attributes: // - outputs: The map of outputs of the corresponding terraform module that lives at the target config of the // dependency. // // This routine will go through the process of obtaining the outputs using `terragrunt output` from the target config. // The traceCtx parameter is the trace context from the parent span (parse_dependencies) to establish parent-child // relationship for individual dependency traces. func dependencyBlocksToCtyValue(traceCtx context.Context, pctx *ParsingContext, l log.Logger, parentConfigPath string, dependencyConfigs []Dependency) (*cty.Value, error) { paths := []string{} // dependencyMap is the top level map that maps dependency block names to the encoded version, which includes // various attributes for accessing information about the target config (including the module outputs). dependencyMap := map[string]cty.Value{} lock := sync.Mutex{} dependencyErrGroup, _ := errgroup.WithContext(traceCtx) for _, dependencyConfig := range dependencyConfigs { dependencyErrGroup.Go(func() error { // Get dependency path for tracing (handle invalid/unknown paths gracefully) // Use getCleanedTargetConfigPath to get the absolute path depPath := "" if IsValidConfigPath(dependencyConfig.ConfigPath) { depPath = getCleanedTargetConfigPath(dependencyConfig.ConfigPath.AsString(), parentConfigPath) } // Use traceCtx to make this a child span of parse_dependencies return TraceParseDependency(traceCtx, dependencyConfig.Name, depPath, func(ctx context.Context) error { // Loose struct to hold the attributes of the dependency. This includes: // - outputs: The module outputs of the target config dependencyEncodingMap := map[string]cty.Value{} // Encode the outputs and nest under `outputs` attribute if we should get the outputs or the `mock_outputs` if err := dependencyConfig.setRenderedOutputs(ctx, pctx, l); err != nil { return err } if dependencyConfig.RenderedOutputs != nil { lock.Lock() paths = append(paths, dependencyConfig.ConfigPath.AsString()) lock.Unlock() dependencyEncodingMap["outputs"] = *dependencyConfig.RenderedOutputs } if dependencyConfig.Inputs != nil { dependencyEncodingMap["inputs"] = *dependencyConfig.Inputs } // Once the dependency is encoded into a map, we need to convert to a cty.Value again so that it can be fed to // the higher order dependency map. dependencyEncodingMapEncoded, err := gocty.ToCtyValue(dependencyEncodingMap, generateTypeFromValuesMap(dependencyEncodingMap)) if err != nil { err = TerragruntOutputListEncodingError{Paths: paths, Err: err} return err } // Lock the map as only one goroutine should be writing to the map at a time lock.Lock() defer lock.Unlock() // Finally, feed the encoded dependency into the higher order map under the block name dependencyMap[dependencyConfig.Name] = dependencyEncodingMapEncoded return nil }) }) } if err := dependencyErrGroup.Wait(); err != nil { return nil, err } // We need to convert the value map to a single cty.Value at the end so that it can be used in the execution ctx convertedOutput, err := gocty.ToCtyValue(dependencyMap, generateTypeFromValuesMap(dependencyMap)) if err != nil { err = TerragruntOutputListEncodingError{Paths: paths, Err: err} } return &convertedOutput, errors.New(err) } // This will attempt to get the outputs from the target terragrunt config if it is applied. If it is not applied, the // behavior is different depending on the configuration of the dependency: // - If the dependency block indicates a mock_outputs attribute, this will return that. // If the dependency block indicates a mock_outputs_merge_strategy_with_state attribute, mock_outputs and state outputs will be merged following the merge strategy // - If the dependency block does NOT indicate a mock_outputs attribute, this will return an error. func getTerragruntOutputIfAppliedElseConfiguredDefault( ctx context.Context, pctx *ParsingContext, l log.Logger, dependencyConfig *Dependency, ) (*cty.Value, error) { if dependencyConfig.isDisabled() { l.Debugf("Skipping outputs reading for disabled dependency %s", dependencyConfig.Name) return dependencyConfig.MockOutputs, nil } if dependencyConfig.shouldGetOutputs(pctx) { outputVal, isEmpty, err := getTerragruntOutput(ctx, pctx, l, dependencyConfig) if err != nil { return nil, err } if !isEmpty && dependencyConfig.shouldMergeMockOutputsWithState(pctx) && dependencyConfig.MockOutputs != nil { mockMergeStrategy := dependencyConfig.getMockOutputsMergeStrategy() switch mockMergeStrategy { // nolint:exhaustive case NoMerge: return outputVal, nil case ShallowMerge: return shallowMergeCtyMaps(*outputVal, *dependencyConfig.MockOutputs) case DeepMergeMapOnly: return deepMergeCtyMapsMapOnly(*dependencyConfig.MockOutputs, *outputVal) default: return nil, errors.New(InvalidMergeStrategyTypeError(mockMergeStrategy)) } } else if !isEmpty { return outputVal, err } } // When we get no output, it can be an indication that either the module has no outputs or the module is not // applied. In either case, check if there are default output values to return. If yes, return that. Else, // return error. targetConfig := getCleanedTargetConfigPath(dependencyConfig.ConfigPath.AsString(), pctx.TerragruntConfigPath) if dependencyConfig.shouldReturnMockOutputs(pctx) { l.Warnf("Config %s is a dependency of %s that has no outputs, but mock outputs provided and returning those in dependency output.", targetConfig, pctx.TerragruntConfigPath, ) return dependencyConfig.MockOutputs, nil } // At this point, we expect outputs to exist because there is a `dependency` block without skip_outputs = true, and // returning mocks is not allowed. So return a useful error message indicating that we expected outputs, but they // did not exist. err := TerragruntOutputTargetNoOutputs{ targetName: dependencyConfig.Name, targetPath: dependencyConfig.ConfigPath.AsString(), targetConfig: targetConfig, currentConfig: pctx.TerragruntConfigPath, } return nil, err } // We should only return default outputs if the mock_outputs attribute is set, and if we are running one of the // allowed commands when `mock_outputs_allowed_terraform_commands` is set as well. func (dep *Dependency) shouldReturnMockOutputs(pctx *ParsingContext) bool { if dep.isDisabled() { return true } defaultOutputsSet := dep.MockOutputs != nil allowedCommand := dep.MockOutputsAllowedTerraformCommands == nil || len(*dep.MockOutputsAllowedTerraformCommands) == 0 || slices.Contains(*dep.MockOutputsAllowedTerraformCommands, pctx.OriginalTerraformCommand) return defaultOutputsSet && allowedCommand || isRenderJSONCommand(pctx) || isRenderCommand(pctx) } // Return the output from the state of another module, managed by terragrunt. This function will parse the provided // terragrunt config and extract the desired output from the remote state. Note that this will error if the targeted // module hasn't been applied yet. func getTerragruntOutput( ctx context.Context, pctx *ParsingContext, l log.Logger, dependencyConfig *Dependency, ) (*cty.Value, bool, error) { // target config check: make sure the target config exists targetConfigPath := getCleanedTargetConfigPath( dependencyConfig.ConfigPath.AsString(), pctx.TerragruntConfigPath, ) if !util.FileExists(targetConfigPath) { return nil, true, errors.New(DependencyConfigNotFound{Path: targetConfigPath}) } jsonBytes, err := getOutputJSONWithCaching(ctx, pctx, l, targetConfigPath) if err != nil { if !isRenderJSONCommand(pctx) && !isRenderCommand(pctx) && !isAwsS3NoSuchKey(err) { return nil, true, err } l.Warnf( "Failed to read outputs from %s referenced in %s as %s, fallback to mock outputs. Error: %v", targetConfigPath, pctx.TerragruntConfigPath, dependencyConfig.Name, err, ) jsonBytes, err = json.Marshal(dependencyConfig.MockOutputs) if err != nil { return nil, true, err } } isEmpty := string(jsonBytes) == "{}" outputMap, err := TerraformOutputJSONToCtyValueMap(targetConfigPath, jsonBytes) if err != nil { return nil, isEmpty, err } // We need to convert the value map to a single cty.Value at the end for use in the terragrunt config. convertedOutput, err := gocty.ToCtyValue(outputMap, generateTypeFromValuesMap(outputMap)) if err != nil { err = TerragruntOutputEncodingError{Path: targetConfigPath, Err: err} } return &convertedOutput, isEmpty, errors.New(err) } func isAwsS3NoSuchKey(err error) bool { if err != nil { errStr := err.Error() return strings.Contains(errStr, "NoSuchKey") || strings.Contains(errStr, "NotFound") } return false } // isRenderJSONCommand This function will true if terragrunt was invoked with render-json func isRenderJSONCommand(pctx *ParsingContext) bool { if pctx.TerraformCliArgs == nil { return false } return pctx.TerraformCliArgs.Contains(renderJSONCommand) } // isRenderCommand will return true if terragrunt was invoked with render func isRenderCommand(pctx *ParsingContext) bool { if pctx.TerraformCliArgs == nil { return false } return pctx.TerraformCliArgs.Contains(renderCommand) } // getOutputJSONWithCaching will run terragrunt output on the target config if it is not already cached. func getOutputJSONWithCaching(ctx context.Context, pctx *ParsingContext, l log.Logger, targetConfig string) ([]byte, error) { locks := outputLocksFromContext(ctx) locks.Lock(targetConfig) defer locks.Unlock(targetConfig) l.Debugf("Getting output of dependency %s for config %s", util.RelPathForLog(pctx.RootWorkingDir, targetConfig, pctx.Writers.LogShowAbsPaths), util.RelPathForLog(pctx.RootWorkingDir, pctx.TerragruntConfigPath, pctx.Writers.LogShowAbsPaths)) jsonCache := cache.ContextCache[[]byte](ctx, JSONOutputCacheContextKey) if jsonBytes, found := jsonCache.Get(ctx, targetConfig); found { l.Debugf("%s was run before. Using cached output.", targetConfig) return jsonBytes, nil } newJSONBytes, err := getTerragruntOutputJSON(ctx, pctx, l, targetConfig) if err != nil { return nil, err } // When AWS Client Side Monitoring (CSM) is enabled the aws-sdk-go displays log as a plaintext "Enabling CSM" to stdout, even if the `output -json` flag is specified. The final output looks like this: "2023/05/04 20:22:43 Enabling CSM{...omitted json string...}", and and prevents proper json parsing. Since there is no way to disable this log, the only way out is to filter. // Related AWS code: https://github.com/aws/aws-sdk-go/blob/81d1cbbc6a2028023aff7bcab0fe1be320cd39f7/aws/session/session.go#L444 // Related issues: https://github.com/gruntwork-io/terragrunt/issues/2233 https://github.com/hashicorp/terraform-provider-aws/issues/23620 if index := bytes.IndexByte(newJSONBytes, byte('{')); index > 0 { newJSONBytes = newJSONBytes[index:] } jsonCache.Put(ctx, targetConfig, newJSONBytes) return newJSONBytes, nil } // Retrieve the outputs from the terraform state in the target configuration. This attempts to optimize the output // retrieval if the following conditions are true: // - State backends are managed with a `remote_state` block. // - The `remote_state` block does not depend on any `dependency` outputs. // If these conditions are met, terragrunt can optimize the retrieval to avoid recursively retrieving dependency outputs // by directly pulling down the state file. Otherwise, terragrunt will fallback to running `terragrunt output` on the // target module. func getTerragruntOutputJSON(ctx context.Context, pctx *ParsingContext, l log.Logger, targetConfig string) ([]byte, error) { // Create dependency context using WithConfigPath l, pctx, err := pctx.WithConfigPath(l, targetConfig) if err != nil { return nil, err } // Set dependency-specific fields pctx.OriginalTerragruntConfigPath = targetConfig pctx.ForwardTFStdout = false pctx.CheckDependentUnits = false pctx.TerraformCommand = "output" pctx.TerraformCliArgs = iacargs.New().SetCommand("output").AppendFlag("-json") // DownloadDir needs to be the dependency's default download directory _, downloadDir := util.DefaultWorkingAndDownloadDirs(targetConfig) pctx.DownloadDir = downloadDir // Clear IAM if changed from original if pctx.IAMRoleOptions != pctx.OriginalIAMRoleOptions { pctx.IAMRoleOptions = iam.RoleOptions{} } // Validate and use TerragruntVersionConstraints.TerraformBinary for dependency partialTerragruntConfig, err := PartialParseConfigFile( ctx, pctx.WithDecodeList(DependencyBlock).WithDiagnosticsSuppressed(l), l, targetConfig, nil, ) if err != nil { return nil, err } // Only override TFPath if it was not explicitly set by the user via CLI or environment variable if !pctx.TFPathExplicitlySet && partialTerragruntConfig.TerraformBinary != "" { pctx.TFPath = partialTerragruntConfig.TerraformBinary } // If the Source is set, then we need to recompute it in the ctx of the target config. if pctx.Source != "" { partialParseIncludedConfig, err := PartialParseConfigFile( ctx, pctx.WithDecodeList(TerraformBlock).WithDiagnosticsSuppressed(l), l, targetConfig, nil, ) if err != nil { return nil, err } // Update the source value to be everything before "//" so that it can be recomputed moduleURL, _ := getter.SourceDirSubdir(pctx.Source) // Finally, update the source to be the combined path between the terraform source in the target config, and the // value before "//" in the original terragrunt options. targetSource, err := GetTerragruntSourceForModule(moduleURL, filepath.Dir(targetConfig), partialParseIncludedConfig) if err != nil { return nil, err } pctx.Source = targetSource } // First attempt to parse the `remote_state` blocks without parsing/getting dependency outputs. If this is possible, // proceed to routine that fetches remote state directly. Otherwise, fallback to calling `terragrunt output` // directly. // we need to suspend logging diagnostic errors on this attempt parseOptions := slices.Concat(pctx.ParserOptions, []hclparse.Option{hclparse.WithDiagnosticsWriter(io.Discard, true)}) remoteStateTGConfig, err := PartialParseConfigFile( ctx, pctx.WithParseOption(parseOptions).WithDecodeList( RemoteStateBlock, TerragruntFlags, EngineBlock, ), l, targetConfig, nil, ) canGet := canGetRemoteState(remoteStateTGConfig.RemoteState) if err != nil || !canGet { l.Debugf("Could not parse remote_state block from target config %s", pctx.TerragruntConfigPath) l.Debugf("Falling back to terragrunt output.") return runTerragruntOutputJSON(ctx, pctx, l, targetConfig) } // In optimization mode, see if there is already an init-ed folder that terragrunt can use, and if so, run // `terraform output` in the working directory. isInit, workingDir, err := terragruntAlreadyInit(ctx, l, pctx, targetConfig) if err != nil { return nil, err } // Fetch engine options so they can be passed to the dependency functions engineOpts, err := remoteStateTGConfig.EngineOptions() if err != nil { return nil, err } pctx.EngineConfig = engineOpts shouldFetchFromState := pctx.Experiments.Evaluate(experiment.DependencyFetchOutputFromState) && !pctx.NoDependencyFetchOutputFromState && remoteStateTGConfig.RemoteState.BackendName == s3backend.BackendName if shouldFetchFromState { return getTerragruntOutputJSONFromRemoteState( ctx, pctx, l, targetConfig, remoteStateTGConfig.RemoteState, remoteStateTGConfig.GetIAMRoleOptions(), ) } if isInit { credsGetter := creds.NewGetter() if err = credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), ); err != nil { return nil, err } return getTerragruntOutputJSONFromInitFolder( ctx, pctx, l, workingDir, remoteStateTGConfig.GetIAMRoleOptions(), credsGetter, ) } return getTerragruntOutputJSONFromRemoteState( ctx, pctx, l, targetConfig, remoteStateTGConfig.RemoteState, remoteStateTGConfig.GetIAMRoleOptions(), ) } // canGetRemoteState returns true if the remote state block is not nil and dependency optimization is not disabled func canGetRemoteState(remoteState *remotestate.RemoteState) bool { return remoteState != nil && !remoteState.DisableDependencyOptimization } // terragruntAlreadyInit returns true if it detects that the module specified by the given terragrunt configuration is // already initialized with the terraform source. This will also return the working directory where you can run // terraform. func terragruntAlreadyInit(ctx context.Context, l log.Logger, pctx *ParsingContext, configPath string) (bool, string, error) { // We need to first determine the working directory where the terraform source should be located. This is dependent // on the source field of the terraform block in the config. terraformBlockTGConfig, err := PartialParseConfigFile(ctx, pctx.WithDecodeList(TerraformSource), l, configPath, nil) if err != nil { return false, "", err } sourceURL, err := GetTerraformSourceURL(pctx.Source, pctx.SourceMap, pctx.OriginalTerragruntConfigPath, terraformBlockTGConfig) if err != nil { return false, "", err } // sourceURL will always be at least "." (current directory) to ensure cache is always used. // Always compute the cache working directory using NewSource. walkWithSymlinks := pctx.Experiments.Evaluate(experiment.Symlinks) terraformSource, err := tf.NewSource(l, sourceURL, pctx.DownloadDir, pctx.WorkingDir, walkWithSymlinks) if err != nil { return false, "", err } // We're only interested in the computed working dir. workingDir := terraformSource.WorkingDir // Terragrunt is already init-ed if the terraform state dir (.terraform) exists in the working dir. // NOTE: if the ref changes, the workingDir would be different as the download dir includes a base64 encoded hash of // the source URL with ref. This would ensure that this routine would not return true if the new ref is not already // init-ed. return util.FileExists(filepath.Join(workingDir, ".terraform")), workingDir, nil } // getTerragruntOutputJSONFromInitFolder will retrieve the outputs directly from the module's working directory without // running init. func getTerragruntOutputJSONFromInitFolder( ctx context.Context, pctx *ParsingContext, l log.Logger, terraformWorkingDir string, iamRoleOpts iam.RoleOptions, credsGetter *creds.Getter, ) ([]byte, error) { targetConfigPath := pctx.TerragruntConfigPath tfRunOpts, err := setupTFRunOptsForBareTerraform( ctx, pctx, l, terraformWorkingDir, iamRoleOpts, credsGetter, ) if err != nil { return nil, err } l.Debugf( "Unit '%s' is already init-ed. "+ "Retrieving outputs directly from working directory.", util.RelPathForLog( pctx.RootWorkingDir, filepath.Dir(targetConfigPath), pctx.Writers.LogShowAbsPaths, ), ) bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) out, err := tf.RunCommandWithOutput(bareCtx, l, tfRunOpts, tf.CommandNameOutput, "-json") if err != nil { return nil, err } jsonString := strings.TrimSpace(out.Stdout.String()) jsonBytes := []byte(jsonString) l.Debugf( "Retrieved output from %s as json: %s", util.RelPathForLog( pctx.RootWorkingDir, targetConfigPath, pctx.Writers.LogShowAbsPaths, ), jsonString, ) return jsonBytes, nil } // getTerragruntOutputJSONFromRemoteState will retrieve the outputs directly by using just the remote state block. This // uses terraform's feature where `output` and `init` can work without the real source, as long as you have the // `backend` configured. // To do this, this function will: // - Create a temporary folder // - Generate the backend.tf file with the backend configuration from the remote_state block // - Copy the provider lock file, if there is one in the dependency's working directory // - Run terraform init and terraform output // - Clean up folder once json file is generated // NOTE: terragruntOptions should be in the ctx of the targetConfig already. func getTerragruntOutputJSONFromRemoteState( ctx context.Context, pctx *ParsingContext, l log.Logger, targetConfigPath string, remoteState *remotestate.RemoteState, iamRoleOpts iam.RoleOptions, ) ([]byte, error) { l.Debugf("Detected remote state block with generate config. Resolving dependency by pulling remote state.") // Create working directory where we will run terraform in. We will create the temporary directory in the download // directory for consistency with other file generation capabilities of terragrunt. Make sure it is cleaned up // before the function returns. if err := util.EnsureDirectory(pctx.DownloadDir); err != nil { return nil, err } tempWorkDir, err := os.MkdirTemp(pctx.DownloadDir, "") if err != nil { return nil, err } defer func(path string) { err := os.RemoveAll(path) if err != nil { l.Warnf("Failed to remove %s: %v", path, err) } }(tempWorkDir) l.Debugf("Setting dependency working directory to %s", tempWorkDir) credsGetter := creds.NewGetter() if err = credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), ); err != nil { return nil, err } tfRunOpts, err := setupTFRunOptsForBareTerraform( ctx, pctx, l, tempWorkDir, iamRoleOpts, credsGetter, ) if err != nil { return nil, err } // To speed up dependencies processing it is possible to retrieve its output directly from the backend without init dependencies if pctx.Experiments.Evaluate(experiment.DependencyFetchOutputFromState) && !pctx.NoDependencyFetchOutputFromState { switch backend := remoteState.BackendName; backend { case s3backend.BackendName: jsonBytes, s3GetErr := getTerragruntOutputJSONFromRemoteStateS3( ctx, l, pctx, remoteState, ) if s3GetErr != nil { return nil, s3GetErr } l.Debugf("Retrieved output from %s as json: %s using s3 bucket", pctx.TerragruntConfigPath, jsonBytes) return jsonBytes, nil default: l.Debugf("dependency-fetch-output-from-state experiment is not supported for backend %s, falling back to default output retrieval", backend) } } // Generate the backend configuration in the working dir. If no generate config is set on the remote state block, // set a temporary generate config so we can generate the backend code. if remoteState.Generate == nil { remoteState.Generate = &remotestate.ConfigGenerate{ Path: "backend.tf", IfExists: codegen.ExistsOverwriteTerragruntStr, } } if err := remoteState.GenerateOpenTofuCode(l, tempWorkDir); err != nil { return nil, err } l.Debugf("Generated remote state configuration in working dir %s", tempWorkDir) // Check for a provider lock file and copy it to the working dir if it exists. terragruntDir := filepath.Dir(pctx.TerragruntConfigPath) if err := CopyLockFile(l, pctx.RootWorkingDir, pctx.Writers.LogShowAbsPaths, terragruntDir, tempWorkDir); err != nil { return nil, err } // The working directory is now set up to interact with the state, so pull it down to get the json output. // Clone pctx and discard init stdout so it doesn't leak into the caller's output buffer. initPctx := pctx.Clone() initPctx.Writers.Writer = io.Discard // First run init to setup the backend configuration so that we can run output. runTerraformInitForDependencyOutput(ctx, initPctx, l, tempWorkDir) // Now that the backend is initialized, run terraform output to get the data and return it. bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) out, err := tf.RunCommandWithOutput(bareCtx, l, tfRunOpts, tf.CommandNameOutput, "-json") if err != nil { return nil, err } jsonString := strings.TrimSpace(out.Stdout.String()) jsonBytes := []byte(jsonString) l.Debugf("Retrieved output from %s as json: %s", targetConfigPath, jsonString) return jsonBytes, nil } // getTerragruntOutputJSONFromRemoteStateS3 pulls the output directly from an S3 bucket without calling Terraform func getTerragruntOutputJSONFromRemoteStateS3(ctx context.Context, l log.Logger, pctx *ParsingContext, remoteState *remotestate.RemoteState) ([]byte, error) { l.Debugf("Fetching outputs directly from s3://%s/%s", remoteState.BackendConfig["bucket"], remoteState.BackendConfig["key"]) s3ConfigExtended, err := s3backend.Config(remoteState.BackendConfig).ParseExtendedS3Config() if err != nil { return nil, err } sessionConfig := s3ConfigExtended.GetAwsSessionConfig() s3Client, err := awshelper.NewAWSConfigBuilder(). WithSessionConfig(sessionConfig). WithEnv(pctx.Env). WithIAMRoleOptions(pctx.IAMRoleOptions). BuildS3Client(ctx, l) if err != nil { return nil, errors.New(err) } result, err := s3Client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(fmt.Sprintf("%s", remoteState.BackendConfig["bucket"])), Key: aws.String(fmt.Sprintf("%s", remoteState.BackendConfig["key"])), }) if err != nil { return nil, err } defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { l.Warnf("Failed to close remote state response %v", err) } }(result.Body) steateBody, err := io.ReadAll(result.Body) if err != nil { return nil, err } jsonState := string(steateBody) jsonMap := make(map[string]any) err = json.Unmarshal([]byte(jsonState), &jsonMap) if err != nil { return nil, err } jsonOutputs, err := json.Marshal(jsonMap["outputs"]) if err != nil { return nil, err } return jsonOutputs, nil } // setupTFRunOptsForBareTerraform builds a *tf.RunOptions that can be used to run terraform // without going through the full RunTerragrunt operation. It merges IAM roles and obtains // credentials inline. func setupTFRunOptsForBareTerraform( ctx context.Context, pctx *ParsingContext, l log.Logger, workingDir string, iamRoleOpts iam.RoleOptions, credsGetter *creds.Getter, ) (*tf.TFOptions, error) { // Merge IAM options mergedIAM := iam.MergeRoleOptions(iamRoleOpts, pctx.OriginalIAMRoleOptions) // Build shell.RunOptions for this specific working dir with io.Discard as writer shellOpts := shellRunOptsFromPctx(pctx) shellOpts.WorkingDir = workingDir shellOpts.Writers.Writer = io.Discard // Make sure to assume any roles set by TG_IAM_ROLE if err := credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellOpts), amazonsts.NewProvider(l, mergedIAM, pctx.Env), ); err != nil { return nil, err } return &tf.TFOptions{ JSONLogFormat: pctx.JSONLogFormat, OriginalTerragruntConfigPath: pctx.OriginalTerragruntConfigPath, ShellOptions: shellOpts, }, nil } // runTerragruntOutputJSON uses terragrunt running functions to extract the json output from the target config. func runTerragruntOutputJSON(ctx context.Context, pctx *ParsingContext, l log.Logger, targetConfig string) ([]byte, error) { // Update the stdout buffer so we can capture the output var stdoutBuffer bytes.Buffer stdoutBufferWriter := bufio.NewWriter(&stdoutBuffer) // Override pctx for this specific operation pctx = pctx.Clone() pctx.ForwardTFStdout = false pctx.JSONLogFormat = false pctx.Writers.Writer = stdoutBufferWriter cfg, err := ParseConfigFile(ctx, pctx, l, pctx.TerragruntConfigPath, nil) if err != nil { return nil, err } runCfg := cfg.ToRunConfig(l) credsGetter := creds.NewGetter() if err = credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), ); err != nil { return nil, err } // Build run.Options directly from ParsingContext fields. // Override Writers.Writer to capture stdout, and force ForwardTFStdout/JSONLogFormat off. runWriters := pctx.Writers runWriters.Writer = stdoutBufferWriter runOpts := &run.Options{ Writers: runWriters, TerragruntConfigPath: pctx.TerragruntConfigPath, OriginalTerragruntConfigPath: pctx.OriginalTerragruntConfigPath, WorkingDir: pctx.WorkingDir, RootWorkingDir: pctx.RootWorkingDir, DownloadDir: pctx.DownloadDir, Source: pctx.Source, SourceMap: pctx.SourceMap, TerraformCommand: pctx.TerraformCommand, OriginalTerraformCommand: pctx.OriginalTerraformCommand, TerraformCliArgs: pctx.TerraformCliArgs, Env: pctx.Env, IAMRoleOptions: pctx.IAMRoleOptions, OriginalIAMRoleOptions: pctx.OriginalIAMRoleOptions, Experiments: pctx.Experiments, StrictControls: pctx.StrictControls, FeatureFlags: pctx.FeatureFlags, EngineConfig: pctx.EngineConfig, EngineOptions: pctx.EngineOptions, TFPath: pctx.TFPath, TofuImplementation: pctx.TofuImplementation, ForwardTFStdout: false, JSONLogFormat: false, Headless: pctx.Headless, Debug: pctx.Debug, AutoInit: pctx.AutoInit, BackendBootstrap: pctx.BackendBootstrap, Telemetry: pctx.Telemetry, AuthProviderCmd: pctx.AuthProviderCmd, } err = run.Run(ctx, l, runOpts, report.NewReport(), runCfg, credsGetter) if err != nil { return nil, errors.New(err) } err = stdoutBufferWriter.Flush() if err != nil { return nil, errors.New(err) } jsonString := strings.TrimSpace(stdoutBuffer.String()) jsonBytes := []byte(jsonString) l.Debugf("Retrieved output from %s as json: %s", targetConfig, jsonString) return jsonBytes, nil } // shellRunOptsFromPctx builds a *shell.RunOptions from ParsingContext flat fields. func shellRunOptsFromPctx(pctx *ParsingContext) *shell.ShellOptions { return &shell.ShellOptions{ Writers: pctx.Writers, EngineOptions: pctx.EngineOptions, WorkingDir: pctx.WorkingDir, Env: pctx.Env, TFPath: pctx.TFPath, EngineConfig: pctx.EngineConfig, Experiments: pctx.Experiments, Telemetry: pctx.Telemetry, RootWorkingDir: pctx.RootWorkingDir, Headless: pctx.Headless, ForwardTFStdout: pctx.ForwardTFStdout, } } // tfRunOptsFromPctx builds a *tf.RunOptions from ParsingContext flat fields. func tfRunOptsFromPctx(pctx *ParsingContext) *tf.TFOptions { return &tf.TFOptions{ JSONLogFormat: pctx.JSONLogFormat, OriginalTerragruntConfigPath: pctx.OriginalTerragruntConfigPath, ShellOptions: shellRunOptsFromPctx(pctx), } } // TerraformOutputJSONToCtyValueMap takes the terraform output json and converts to a mapping between output keys to the // parsed cty.Value encoding of the json objects. func TerraformOutputJSONToCtyValueMap(targetConfigPath string, jsonBytes []byte) (map[string]cty.Value, error) { // When getting all outputs, terraform returns a json with the data containing metadata about the types, so we // can't quite return the data directly. Instead, we will need further processing to get the output we want. // To do so, we first Unmarshal the json into a simple go map to a OutputMeta struct. type OutputMeta struct { Type json.RawMessage `json:"type"` Value json.RawMessage `json:"value"` Sensitive bool `json:"sensitive"` } var outputs map[string]OutputMeta err := json.Unmarshal(jsonBytes, &outputs) if err != nil { return nil, errors.New(TerragruntOutputParsingError{Path: targetConfigPath, Err: err}) } flattenedOutput := map[string]cty.Value{} for k, v := range outputs { outputType, err := ctyjson.UnmarshalType(v.Type) if err != nil { return nil, errors.New(TerragruntOutputParsingError{Path: targetConfigPath, Err: err}) } outputVal, err := ctyjson.Unmarshal(v.Value, outputType) if err != nil { return nil, errors.New(TerragruntOutputParsingError{Path: targetConfigPath, Err: err}) } flattenedOutput[k] = outputVal } return flattenedOutput, nil } // runTerraformInitForDependencyOutput will run terraform init in a mode that doesn't pull down plugins or modules. Note // that this will cause the command to fail for most modules as terraform init does a validation check to make sure the // plugins are available, even though we don't need it for our purposes (terraform output does not depend on any of the // plugins being available). As such this command will ignore errors in the init command. // To help with debuggability, the errors will be printed to the console when TG_LOG=debug is set. func runTerraformInitForDependencyOutput(ctx context.Context, pctx *ParsingContext, l log.Logger, workingDir string) { stderr := bytes.Buffer{} initRunOpts := tfRunOptsFromPctx(pctx) initRunOpts.ShellOptions.WorkingDir = workingDir initRunOpts.ShellOptions.Writers.ErrWriter = &stderr bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) if err := tf.RunCommand(bareCtx, l, initRunOpts, tf.CommandNameInit, "-get=false"); err != nil { l.Debugf("Ignoring expected error from dependency init call") l.Debugf("Init call stderr:") l.Debugf("%s", stderr.String()) } } func (deps Dependencies) FilteredWithoutConfigPath() Dependencies { var filteredDeps Dependencies for _, dep := range deps { if !dep.ConfigPath.IsNull() { filteredDeps = append(filteredDeps, dep) } } return filteredDeps } // IsValidConfigPath checks if a cty.Value is a valid, usable config path. func IsValidConfigPath(v cty.Value) bool { if v.IsNull() || !v.IsWhollyKnown() || !v.Type().Equals(cty.String) { return false } // Empty string is not a valid config path if v.AsString() == "" { return false } return true } ================================================ FILE: pkg/config/dependency_inputs_test.go ================================================ package config_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDependencyInputsBlockedByDefault(t *testing.T) { t.Parallel() // Test that dependency.foo.inputs syntax is now blocked by default configWithDependencyInputs := ` dependency "dep" { config_path = "../dep" } inputs = { value = dependency.dep.inputs.some_value } ` parser := hclparse.NewParser() file, err := parser.ParseFromString(configWithDependencyInputs, "terragrunt.hcl") require.NoError(t, err) // Create a parsing context with strict controls pctx := &config.ParsingContext{ StrictControls: controls.New(), } logger := log.New() // Test that the deprecated configuration is detected and blocked err = config.DetectDeprecatedConfigurations(t.Context(), pctx, logger, file) require.Error(t, err) assert.Contains(t, err.Error(), "Reading inputs from dependencies is no longer supported") assert.Contains(t, err.Error(), "use outputs") } func TestDependencyOutputsStillAllowed(t *testing.T) { t.Parallel() // Test that dependency.foo.outputs syntax still works fine configWithDependencyOutputs := ` dependency "dep" { config_path = "../dep" } inputs = { value = dependency.dep.outputs.some_value } ` parser := hclparse.NewParser() file, err := parser.ParseFromString(configWithDependencyOutputs, "terragrunt.hcl") require.NoError(t, err) // Create a parsing context with strict controls pctx := &config.ParsingContext{ StrictControls: controls.New(), } logger := log.New() // Test that the dependency outputs are allowed (no error) err = config.DetectDeprecatedConfigurations(t.Context(), pctx, logger, file) require.NoError(t, err) } func TestDetectInputsCtyUsageFunction(t *testing.T) { t.Parallel() testCases := []struct { name string config string expected bool }{ { name: "dependency inputs detected", config: ` inputs = { value = dependency.dep.inputs.some_value } `, expected: true, }, { name: "dependency outputs not detected", config: ` inputs = { value = dependency.dep.outputs.some_value } `, expected: false, }, { name: "no dependency references", config: ` inputs = { value = "static_value" } `, expected: false, }, { name: "multiple dependency inputs detected", config: ` inputs = { value1 = dependency.dep1.inputs.val1 value2 = dependency.dep2.inputs.val2 } `, expected: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() parser := hclparse.NewParser() file, err := parser.ParseFromString(tc.config, "terragrunt.hcl") require.NoError(t, err) result := config.DetectInputsCtyUsage(file) assert.Equal(t, tc.expected, result) }) } } ================================================ FILE: pkg/config/dependency_test.go ================================================ package config_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/gruntwork-io/go-commons/env" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ) func TestDecodeDependencyBlockMultiple(t *testing.T) { t.Parallel() cfg := ` dependency "vpc" { config_path = "../vpc" } dependency "sql" { config_path = "../sql" } ` filename := config.DefaultTerragruntConfigPath file, err := hclparse.NewParser().ParseFromString(cfg, filename) require.NoError(t, err) decoded := config.TerragruntDependency{} require.NoError(t, file.Decode(&decoded, &hcl.EvalContext{})) assert.Len(t, decoded.Dependencies, 2) assert.Equal(t, "vpc", decoded.Dependencies[0].Name) assert.Equal(t, cty.StringVal("../vpc"), decoded.Dependencies[0].ConfigPath) assert.Equal(t, "sql", decoded.Dependencies[1].Name) assert.Equal(t, cty.StringVal("../sql"), decoded.Dependencies[1].ConfigPath) } func TestDecodeNoDependencyBlock(t *testing.T) { t.Parallel() cfg := ` locals { path = "../vpc" } ` filename := config.DefaultTerragruntConfigPath file, err := hclparse.NewParser().ParseFromString(cfg, filename) require.NoError(t, err) decoded := config.TerragruntDependency{} require.NoError(t, file.Decode(&decoded, &hcl.EvalContext{})) assert.Empty(t, decoded.Dependencies) } func TestDecodeDependencyNoLabelIsError(t *testing.T) { t.Parallel() cfg := ` dependency { config_path = "../vpc" } ` filename := config.DefaultTerragruntConfigPath file, err := hclparse.NewParser().ParseFromString(cfg, filename) require.NoError(t, err) decoded := config.TerragruntDependency{} require.Error(t, file.Decode(&decoded, &hcl.EvalContext{})) } func TestDecodeDependencyMockOutputs(t *testing.T) { t.Parallel() cfg := ` dependency "hitchhiker" { config_path = "../answers" mock_outputs = { the_answer = 42 } mock_outputs_allowed_terraform_commands = ["validate", "apply"] } ` filename := config.DefaultTerragruntConfigPath file, err := hclparse.NewParser().ParseFromString(cfg, filename) require.NoError(t, err) decoded := config.TerragruntDependency{} require.NoError(t, file.Decode(&decoded, &hcl.EvalContext{})) assert.Len(t, decoded.Dependencies, 1) dependency := decoded.Dependencies[0] assert.Equal(t, "hitchhiker", dependency.Name) assert.Equal(t, cty.StringVal("../answers"), dependency.ConfigPath) ctyValueDefault := dependency.MockOutputs assert.NotNil(t, ctyValueDefault) var actualDefault struct { TheAnswer int `cty:"the_answer"` } require.NoError(t, gocty.FromCtyValue(*ctyValueDefault, &actualDefault)) assert.Equal(t, 42, actualDefault.TheAnswer) defaultAllowedCommands := dependency.MockOutputsAllowedTerraformCommands assert.NotNil(t, defaultAllowedCommands) assert.Equal(t, []string{"validate", "apply"}, *defaultAllowedCommands) } func TestParseDependencyBlockMultiple(t *testing.T) { t.Parallel() filename, err := filepath.Abs(filepath.Join("../..", "test", "fixtures", "regressions", "multiple-dependency-load-sync", "main", "terragrunt.hcl")) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, filename) err = pctx.Experiments.EnableExperiment(experiment.DependencyFetchOutputFromState) require.NoError(t, err) pctx.Env = env.Parse(os.Environ()) tfConfig, err := config.ParseConfigFile(ctx, pctx, logger.CreateLogger(), filename, nil) require.NoError(t, err) assert.Len(t, tfConfig.TerragruntDependencies, 2) assert.Equal(t, "dependency_1", tfConfig.TerragruntDependencies[0].Name) assert.Equal(t, "dependency_2", tfConfig.TerragruntDependencies[1].Name) } func TestDisabledDependency(t *testing.T) { t.Parallel() cfg := ` dependency "ec2" { config_path = "../ec2" enabled = false } dependency "vpc" { config_path = "../vpc" } ` filename := config.DefaultTerragruntConfigPath file, err := hclparse.NewParser().ParseFromString(cfg, filename) require.NoError(t, err) decoded := config.TerragruntDependency{} require.NoError(t, file.Decode(&decoded, &hcl.EvalContext{})) assert.Len(t, decoded.Dependencies, 2) } // TestDisabledDependencyWithNullConfigPath verifies that disabled dependencies // with null config_path don't panic during parsing (they bypass validation). func TestDisabledDependencyWithNullConfigPath(t *testing.T) { t.Parallel() // This config has a disabled dependency with config_path that would fail // validation if it were enabled (uses a local that resolves to null) cfg := ` locals { disabled_path = null } dependency "disabled" { config_path = local.disabled_path enabled = false } dependency "enabled" { config_path = "../vpc" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) // Should not panic - disabled deps bypass config_path validation terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) // Only enabled dependency should be in the paths assert.Len(t, terragruntConfig.Dependencies.Paths, 1) } // TestDisabledDependencyWithEmptyConfigPath verifies that disabled dependencies // with empty config_path don't cause errors. func TestDisabledDependencyWithEmptyConfigPath(t *testing.T) { t.Parallel() cfg := ` dependency "disabled" { config_path = "" enabled = false } dependency "enabled" { config_path = "../vpc" } ` l := logger.CreateLogger() ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) pctx = pctx.WithDecodeList(config.DependencyBlock) // Should not error - disabled deps bypass config_path validation terragruntConfig, err := config.PartialParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, cfg, nil) require.NoError(t, err) // Only enabled dependency should be in the paths assert.Len(t, terragruntConfig.Dependencies.Paths, 1) } ================================================ FILE: pkg/config/engine.go ================================================ package config import ( "github.com/zclconf/go-cty/cty" ) // EngineConfig represents the structure of the HCL data type EngineConfig struct { Version *string `hcl:"version,attr" cty:"version"` Type *string `hcl:"type,attr" cty:"type"` Meta *cty.Value `hcl:"meta,attr" cty:"meta"` Source string `hcl:"source,attr" cty:"source"` } // Clone returns a copy of the EngineConfig used in deep copy func (c *EngineConfig) Clone() *EngineConfig { return &EngineConfig{ Source: c.Source, Version: c.Version, Type: c.Type, Meta: c.Meta, } } // Merge merges the EngineConfig with another EngineConfig func (c *EngineConfig) Merge(engine *EngineConfig) { if engine.Source != "" { c.Source = engine.Source } if engine.Version != nil { c.Version = engine.Version } if engine.Type != nil { c.Type = engine.Type } if engine.Meta != nil { c.Meta = engine.Meta } } ================================================ FILE: pkg/config/errors.go ================================================ package config import ( "fmt" "strings" ) // Custom error types type InvalidArgError string func (e InvalidArgError) Error() string { return string(e) } type IncludedConfigMissingPathError string func (err IncludedConfigMissingPathError) Error() string { return fmt.Sprintf("The include configuration in %s must specify a 'path' parameter", string(err)) } type IncludeConfigNotFoundError struct { IncludePath string SourcePath string } func (err IncludeConfigNotFoundError) Error() string { return fmt.Sprintf("Include configuration not found: %s (referenced from: %s)", err.IncludePath, err.SourcePath) } type TooManyLevelsOfInheritanceError struct { ConfigPath string FirstLevelIncludePath string SecondLevelIncludePath string } func (err TooManyLevelsOfInheritanceError) Error() string { return fmt.Sprintf("%s includes %s, which itself includes %s. Only one level of includes is allowed.", err.ConfigPath, err.FirstLevelIncludePath, err.SecondLevelIncludePath) } type CouldNotResolveTerragruntConfigInFileError string func (err CouldNotResolveTerragruntConfigInFileError) Error() string { return "Could not find Terragrunt configuration settings in " + string(err) } type InvalidMergeStrategyTypeError string func (err InvalidMergeStrategyTypeError) Error() string { return fmt.Sprintf( "Include merge strategy %s is unknown. Valid strategies are: %s, %s, %s, %s", string(err), NoMerge, ShallowMerge, DeepMerge, DeepMergeMapOnly, ) } type DependencyDirNotFoundError struct { Dir []string } func (err DependencyDirNotFoundError) Error() string { return fmt.Sprintf( "Found paths in the 'dependencies' block that do not exist: %v", err.Dir, ) } type DuplicatedGenerateBlocksError struct { BlockName []string } func (err DuplicatedGenerateBlocksError) Error() string { return fmt.Sprintf( "Detected generate blocks with the same name: %v", err.BlockName, ) } type TFVarFileNotFoundError struct { File string Cause string } func (err TFVarFileNotFoundError) Error() string { return fmt.Sprintf("TFVarFileNotFound: Could not find a %s. Cause: %s.", err.File, err.Cause) } type WrongNumberOfParamsError struct { Func string Expected string Actual int } func (err WrongNumberOfParamsError) Error() string { return fmt.Sprintf("Expected %s params for function %s, but got %d", err.Expected, err.Func, err.Actual) } type InvalidParameterTypeError struct { Expected string Actual string } func (err InvalidParameterTypeError) Error() string { return fmt.Sprintf("Expected param of type %s but got %s", err.Expected, err.Actual) } type ParentFileNotFoundError struct { Path string File string Cause string } func (err ParentFileNotFoundError) Error() string { return fmt.Sprintf("ParentFileNotFoundError: Could not find a %s in any of the parent folders of %s. Cause: %s.", err.File, err.Path, err.Cause) } type InvalidGetEnvParamsError struct { Example string ActualNumParams int } func (err InvalidGetEnvParamsError) Error() string { return fmt.Sprintf("InvalidGetEnvParamsError: Expected one or two parameters (%s) for get_env but got %d.", err.Example, err.ActualNumParams) } type EnvVarNotFoundError struct { EnvVar string } func (err EnvVarNotFoundError) Error() string { return fmt.Sprintf("EnvVarNotFoundError: Required environment variable %s - not found", err.EnvVar) } type InvalidEnvParamNameError struct { EnvName string } func (err InvalidEnvParamNameError) Error() string { return fmt.Sprintf("InvalidEnvParamNameError: Invalid environment variable name - (%s) ", err.EnvName) } type EmptyStringNotAllowedError string func (err EmptyStringNotAllowedError) Error() string { return "Empty string value is not allowed for " + string(err) } type ConflictingRunCmdCacheOptionsError struct{} func (err ConflictingRunCmdCacheOptionsError) Error() string { return "The --terragrunt-global-cache and --terragrunt-no-cache options cannot be used together. Choose one caching option for run_cmd." } type TerragruntConfigNotFoundError struct { Path string } func (err TerragruntConfigNotFoundError) Error() string { return fmt.Sprintf("You attempted to run terragrunt in a folder that does not contain a terragrunt.hcl file. Please add a terragrunt.hcl file and try again.\n\nPath: %q", err.Path) } type InvalidSourceURLError struct { ModulePath string ModuleSourceURL string TerragruntSource string } func (err InvalidSourceURLError) Error() string { return fmt.Sprintf("The --source parameter is set to '%s', but the source URL in the module at '%s' is invalid: '%s'. Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!", err.TerragruntSource, err.ModulePath, err.ModuleSourceURL) } type InvalidSourceURLWithMapError struct { ModulePath string ModuleSourceURL string } func (err InvalidSourceURLWithMapError) Error() string { return fmt.Sprintf("The --source-map parameter was passed in, but the source URL in the module at '%s' is invalid: '%s'. Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!", err.ModulePath, err.ModuleSourceURL) } type ParsingModulePathError struct { ModuleSourceURL string } func (err ParsingModulePathError) Error() string { return fmt.Sprintf("Unable to obtain the module path from the source URL '%s'. Ensure that the URL is in a supported format.", err.ModuleSourceURL) } type InvalidSopsFormatError struct { SourceFilePath string } func (err InvalidSopsFormatError) Error() string { return fmt.Sprintf("File %s is not a valid format or encoding. Terragrunt will only decrypt yaml or json files in UTF-8 encoding.", err.SourceFilePath) } type InvalidIncludeKeyError struct { name string } func (err InvalidIncludeKeyError) Error() string { return fmt.Sprintf("There is no include block in the current config with the label '%s'", err.name) } type DependencyFileNotFoundError struct { Path string } func (err DependencyFileNotFoundError) Error() string { return "Dependency file not found: " + err.Path } // Dependency Custom error types type DependencyConfigNotFound struct { Path string } func (err DependencyConfigNotFound) Error() string { return err.Path + " does not exist" } type TerragruntOutputParsingError struct { Err error Path string } func (err TerragruntOutputParsingError) Error() string { return fmt.Sprintf("Could not parse output from terragrunt config %s. Underlying error: %s", err.Path, err.Err) } type TerragruntOutputEncodingError struct { Err error Path string } func (err TerragruntOutputEncodingError) Error() string { return fmt.Sprintf("Could not encode output from terragrunt config %s. Underlying error: %s", err.Path, err.Err) } type TerragruntOutputListEncodingError struct { Err error Paths []string } func (err TerragruntOutputListEncodingError) Error() string { return fmt.Sprintf("Could not encode output from list of terragrunt configs %v. Underlying error: %s", err.Paths, err.Err) } type TerragruntOutputTargetNoOutputs struct { targetName string targetPath string targetConfig string currentConfig string } func (err TerragruntOutputTargetNoOutputs) ExitCode() int { return 1 } func (err TerragruntOutputTargetNoOutputs) Unwrap() error { return nil } func (err TerragruntOutputTargetNoOutputs) Error() string { msg := ` If this dependency is accessed before the outputs are ready (which can happen during the planning phase of an unapplied stack), consider using mock_outputs: dependency "` + err.targetName + `" { config_path = "` + err.targetPath + `" mock_outputs = { ` + err.targetName + `_output = "mock-` + err.targetName + `-output" } } For more info, see: https://docs.terragrunt.com/features/stacks/#unapplied-dependency-and-mock-outputs If you do not require outputs from your dependency, consider using the dependencies block instead: https://docs.terragrunt.com/reference/config-blocks-and-attributes/#dependencies ` return fmt.Sprintf( "%s is a dependency of %s but detected no outputs. Either the target module has not been applied yet, or the module has no outputs.\n%s", err.targetConfig, err.currentConfig, msg, ) } type DependencyCycleError []string func (err DependencyCycleError) Error() string { return "Found a dependency cycle between modules: " + strings.Join([]string(err), " -> ") } type DependencyInvalidConfigPathError struct { DependencyName string } func (err DependencyInvalidConfigPathError) Error() string { return fmt.Sprintf("dependency %q has invalid config_path", err.DependencyName) } // MaxParseDepthError is returned when config parsing exceeds the maximum allowed depth. type MaxParseDepthError struct { Depth int Max int } func (err MaxParseDepthError) Error() string { return fmt.Sprintf("maximum parse depth of %d exceeded (current depth: %d). This usually indicates circular includes or extremely deep config nesting.", err.Max, err.Depth) } ================================================ FILE: pkg/config/errors_block.go ================================================ package config import ( "maps" "slices" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/zclconf/go-cty/cty" ) // ErrorsConfig represents the top-level errors configuration type ErrorsConfig struct { Retry []*RetryBlock `cty:"retry" hcl:"retry,block"` Ignore []*IgnoreBlock `cty:"ignore" hcl:"ignore,block"` } // RetryBlock represents a labeled retry block type RetryBlock struct { Label string `cty:"name" hcl:"name,label"` RetryableErrors []string `cty:"retryable_errors" hcl:"retryable_errors"` MaxAttempts int `cty:"max_attempts" hcl:"max_attempts"` SleepIntervalSec int `cty:"sleep_interval_sec" hcl:"sleep_interval_sec"` } // IgnoreBlock represents a labeled ignore block type IgnoreBlock struct { Signals map[string]cty.Value `cty:"signals" hcl:"signals,optional"` Label string `cty:"name" hcl:"name,label"` Message string `cty:"message" hcl:"message,optional"` IgnorableErrors []string `cty:"ignorable_errors" hcl:"ignorable_errors"` } // Clone returns a deep copy of ErrorsConfig func (c *ErrorsConfig) Clone() *ErrorsConfig { if c == nil { return nil } return &ErrorsConfig{ Retry: cloneRetryBlocks(c.Retry), Ignore: cloneIgnoreBlocks(c.Ignore), } } // Merge combines the current ErrorsConfig with another one, prioritizing the other config func (c *ErrorsConfig) Merge(other *ErrorsConfig) { if c == nil || other == nil { return } c.Retry = mergeRetryBlocks(c.Retry, other.Retry) c.Ignore = mergeIgnoreBlocks(c.Ignore, other.Ignore) } // Clone returns a deep copy of a RetryBlock func (r *RetryBlock) Clone() *RetryBlock { if r == nil { return nil } return &RetryBlock{ Label: r.Label, RetryableErrors: cloneStringSlice(r.RetryableErrors), MaxAttempts: r.MaxAttempts, SleepIntervalSec: r.SleepIntervalSec, } } // Clone returns a deep copy of an IgnoreBlock func (i *IgnoreBlock) Clone() *IgnoreBlock { if i == nil { return nil } return &IgnoreBlock{ Label: i.Label, IgnorableErrors: cloneStringSlice(i.IgnorableErrors), Message: i.Message, Signals: cloneSignalsMap(i.Signals), } } // Helper function to deep copy a slice of RetryBlock func cloneRetryBlocks(blocks []*RetryBlock) []*RetryBlock { if blocks == nil { return nil } cloned := make([]*RetryBlock, len(blocks)) for i, block := range blocks { cloned[i] = block.Clone() } return cloned } // Helper function to deep copy a slice of IgnoreBlock func cloneIgnoreBlocks(blocks []*IgnoreBlock) []*IgnoreBlock { if blocks == nil { return nil } cloned := make([]*IgnoreBlock, len(blocks)) for i, block := range blocks { cloned[i] = block.Clone() } return cloned } // Helper function to deep copy a slice of strings func cloneStringSlice(slice []string) []string { if slice == nil { return nil } cloned := make([]string, len(slice)) copy(cloned, slice) return cloned } // Helper function to deep copy a map of signals func cloneSignalsMap(signals map[string]cty.Value) map[string]cty.Value { if signals == nil { return nil } cloned := make(map[string]cty.Value, len(signals)) maps.Copy(cloned, signals) return cloned } // Merges two slices of RetryBlock, prioritizing the second slice func mergeRetryBlocks(existing, other []*RetryBlock) []*RetryBlock { retryMap := make(map[string]*RetryBlock, len(existing)+len(other)) // Add existing retry blocks for _, block := range existing { retryMap[block.Label] = block } // Merge retry blocks from 'other' for _, otherBlock := range other { if existingBlock, found := retryMap[otherBlock.Label]; found { existingBlock.RetryableErrors = util.MergeSlices(existingBlock.RetryableErrors, otherBlock.RetryableErrors) if otherBlock.MaxAttempts > 0 { existingBlock.MaxAttempts = otherBlock.MaxAttempts } if otherBlock.SleepIntervalSec > 0 { existingBlock.SleepIntervalSec = otherBlock.SleepIntervalSec } continue } retryMap[otherBlock.Label] = otherBlock } return slices.Collect(maps.Values(retryMap)) } // Merges two slices of IgnoreBlock, prioritizing the second slice func mergeIgnoreBlocks(existing, other []*IgnoreBlock) []*IgnoreBlock { ignoreMap := make(map[string]*IgnoreBlock, len(existing)+len(other)) // Add existing ignore blocks for _, block := range existing { ignoreMap[block.Label] = block } // Merge ignore blocks from 'other' for _, otherBlock := range other { if existingBlock, found := ignoreMap[otherBlock.Label]; found { existingBlock.IgnorableErrors = util.MergeSlices(existingBlock.IgnorableErrors, otherBlock.IgnorableErrors) if otherBlock.Message != "" { existingBlock.Message = otherBlock.Message } if otherBlock.Signals != nil { if existingBlock.Signals == nil { existingBlock.Signals = make(map[string]cty.Value, len(otherBlock.Signals)) } maps.Copy(existingBlock.Signals, otherBlock.Signals) } } else { ignoreMap[otherBlock.Label] = otherBlock } } // Convert map back to slice return slices.Collect(maps.Values(ignoreMap)) } ================================================ FILE: pkg/config/exclude.go ================================================ package config import ( "context" "strconv" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/zclconf/go-cty/cty" ) // bool values to be used as booleans. var boolFlagValues = []string{"if", "exclude_dependencies", "no_run"} // ExcludeConfig configurations for hcl files. type ExcludeConfig struct { ExcludeDependencies *bool `cty:"exclude_dependencies" hcl:"exclude_dependencies,attr" json:"exclude_dependencies"` NoRun *bool `cty:"no_run" hcl:"no_run,attr" json:"no_run"` Actions []string `cty:"actions" hcl:"actions,attr" json:"actions"` If bool `cty:"if" hcl:"if,attr" json:"if"` } // IsActionListed checks if the action is listed in the exclude block. func (e *ExcludeConfig) IsActionListed(action string) bool { return runcfg.IsActionListedInExclude(e.Actions, action) } // ShouldPreventRun checks if the unit should be prevented from running based on the no_run attribute and current action. func (e *ExcludeConfig) ShouldPreventRun(action string) bool { return runcfg.ShouldPreventRunBasedOnExclude(e.Actions, e.NoRun, e.If, action) } // Clone returns a new instance of ExcludeConfig with the same values as the original. func (e *ExcludeConfig) Clone() *ExcludeConfig { return &ExcludeConfig{ If: e.If, Actions: e.Actions, ExcludeDependencies: e.ExcludeDependencies, NoRun: e.NoRun, } } // Merge merges the values of the provided ExcludeConfig into the original. func (e *ExcludeConfig) Merge(exclude *ExcludeConfig) { // copy not empty fields e.If = exclude.If if len(exclude.Actions) > 0 { e.Actions = exclude.Actions } e.ExcludeDependencies = exclude.ExcludeDependencies e.NoRun = exclude.NoRun } // evaluateExcludeBlocks evaluates the exclude block in the parsed file. func evaluateExcludeBlocks(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File) (*ExcludeConfig, error) { excludeBlock, err := file.Blocks(MetadataExclude, false) if err != nil { return nil, err } if len(excludeBlock) == 0 { return nil, nil } if len(excludeBlock) > 1 { // only one block allowed return nil, errors.Errorf("Only one %s block is allowed found multiple in %s", MetadataExclude, file.ConfigPath) } attrs, err := excludeBlock[0].JustAttributes() if err != nil { l.Debugf("Encountered error while decoding exclude block.") return nil, err } evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { l.Errorf("Failed to create eval context %s", file.ConfigPath) return nil, err } evaluatedAttrs := map[string]cty.Value{} for _, attr := range attrs { value, err := attr.Value(evalCtx) if err != nil { l.Debugf("Encountered error while evaluating exclude block in file %s", file.ConfigPath) return nil, err } evaluatedAttrs[attr.Name] = value } for _, boolFlag := range boolFlagValues { if value, ok := evaluatedAttrs[boolFlag]; ok { if value.Type() == cty.String { // handle bool flag value val, err := strconv.ParseBool(value.AsString()) if err != nil { return nil, errors.New(err) } evaluatedAttrs[boolFlag] = cty.BoolVal(val) } } } excludeAsCtyVal, err := ConvertValuesMapToCtyVal(evaluatedAttrs) if err != nil { return nil, err } // convert cty map to ExcludeConfig excludeConfig := &ExcludeConfig{} if err := CtyToStruct(excludeAsCtyVal, excludeConfig); err != nil { return nil, errors.Unwrap(err) } return excludeConfig, nil } ================================================ FILE: pkg/config/external_test.go ================================================ // This file validates that the pkg/config package is usable by external consumers // as a public API. All tests here use only the external (black-box) package name // `config_test` and import only public packages — no `internal/` imports are allowed. package config_test import ( "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func createExternalLogger() log.Logger { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)) } func TestExternalConstants(t *testing.T) { t.Parallel() assert.Equal(t, "terragrunt.hcl", config.DefaultTerragruntConfigPath) assert.Equal(t, "terragrunt.stack.hcl", config.DefaultStackFile) assert.Equal(t, ".terragrunt-stack", config.StackDir) assert.Equal(t, "terragrunt.hcl.json", config.DefaultTerragruntJSONConfigPath) assert.Equal(t, "root.hcl", config.RecommendedParentConfigName) assert.Equal(t, "found_in_file", config.FoundInFile) assert.NotEmpty(t, config.DefaultTerragruntConfigPaths) } func TestExternalTerragruntConfigStruct(t *testing.T) { t.Parallel() cfg := &config.TerragruntConfig{ TerraformBinary: "tofu", TerragruntVersionConstraint: ">= 0.50.0", TerraformVersionConstraint: ">= 1.5.0", DownloadDir: "/tmp/download", IamRole: "arn:aws:iam::123456789012:role/test", IamAssumeRoleSessionName: "test-session", IamWebIdentityToken: "token-value", Inputs: map[string]any{"key": "value"}, Locals: map[string]any{"local_key": "local_value"}, IsPartial: false, } assert.Equal(t, "tofu", cfg.TerraformBinary) assert.Equal(t, ">= 0.50.0", cfg.TerragruntVersionConstraint) assert.Equal(t, ">= 1.5.0", cfg.TerraformVersionConstraint) assert.Equal(t, "/tmp/download", cfg.DownloadDir) assert.Equal(t, "arn:aws:iam::123456789012:role/test", cfg.IamRole) assert.Equal(t, "test-session", cfg.IamAssumeRoleSessionName) assert.Equal(t, "token-value", cfg.IamWebIdentityToken) assert.Equal(t, map[string]any{"key": "value"}, cfg.Inputs) assert.Equal(t, map[string]any{"local_key": "local_value"}, cfg.Locals) assert.False(t, cfg.IsPartial) assert.Nil(t, cfg.Terraform) assert.Nil(t, cfg.RemoteState) assert.Nil(t, cfg.Dependencies) assert.Nil(t, cfg.Engine) assert.Nil(t, cfg.PreventDestroy) } func TestExternalTerragruntConfigAsCty(t *testing.T) { t.Parallel() cfg := &config.TerragruntConfig{ TerraformBinary: "terraform", Inputs: map[string]any{"env": "dev"}, } ctyVal, err := config.TerragruntConfigAsCty(cfg) require.NoError(t, err) assert.True(t, ctyVal.IsKnown()) assert.True(t, ctyVal.Type().IsObjectType()) } func TestExternalGetTerraformSourceURL(t *testing.T) { t.Parallel() t.Run("explicit source overrides config", func(t *testing.T) { t.Parallel() result, err := config.GetTerraformSourceURL("explicit-source", nil, "config.hcl", &config.TerragruntConfig{}) require.NoError(t, err) assert.Equal(t, "explicit-source", result) }) t.Run("no source returns dot", func(t *testing.T) { t.Parallel() result, err := config.GetTerraformSourceURL("", nil, "config.hcl", &config.TerragruntConfig{}) require.NoError(t, err) assert.Equal(t, ".", result) }) } func TestExternalEngineConfig(t *testing.T) { t.Parallel() version := "1.0.0" engineType := "rpc" engine := &config.EngineConfig{ Source: "github.com/example/engine", Version: &version, Type: &engineType, } t.Run("clone", func(t *testing.T) { t.Parallel() cloned := engine.Clone() assert.Equal(t, engine.Source, cloned.Source) assert.Equal(t, *engine.Version, *cloned.Version) assert.Equal(t, *engine.Type, *cloned.Type) }) t.Run("merge", func(t *testing.T) { t.Parallel() base := &config.EngineConfig{ Source: "original-source", } newVersion := "2.0.0" override := &config.EngineConfig{ Source: "new-source", Version: &newVersion, } base.Merge(override) assert.Equal(t, "new-source", base.Source) assert.Equal(t, "2.0.0", *base.Version) }) } func TestExternalCtyHelpers(t *testing.T) { t.Parallel() t.Run("GetValueString with string", func(t *testing.T) { t.Parallel() result, err := config.GetValueString(cty.StringVal("hello")) require.NoError(t, err) assert.Equal(t, "hello", result) }) t.Run("GetValueString with number", func(t *testing.T) { t.Parallel() result, err := config.GetValueString(cty.NumberIntVal(42)) require.NoError(t, err) assert.NotEmpty(t, result) }) t.Run("GetFirstKey", func(t *testing.T) { t.Parallel() m := map[string]cty.Value{"only_key": cty.StringVal("val")} assert.Equal(t, "only_key", config.GetFirstKey(m)) }) t.Run("GetFirstKey empty map", func(t *testing.T) { t.Parallel() assert.Empty(t, config.GetFirstKey(map[string]cty.Value{})) }) t.Run("IsComplexType", func(t *testing.T) { t.Parallel() assert.False(t, config.IsComplexType(cty.StringVal("simple"))) assert.False(t, config.IsComplexType(cty.NumberIntVal(1))) assert.True(t, config.IsComplexType(cty.ObjectVal(map[string]cty.Value{"k": cty.StringVal("v")}))) assert.True(t, config.IsComplexType(cty.ListVal([]cty.Value{cty.StringVal("a")}))) }) t.Run("ConvertValuesMapToCtyVal", func(t *testing.T) { t.Parallel() valMap := map[string]cty.Value{ "str": cty.StringVal("value"), "num": cty.NumberIntVal(10), } result, err := config.ConvertValuesMapToCtyVal(valMap) require.NoError(t, err) assert.True(t, result.Type().IsObjectType()) }) t.Run("ConvertValuesMapToCtyVal empty", func(t *testing.T) { t.Parallel() result, err := config.ConvertValuesMapToCtyVal(map[string]cty.Value{}) require.NoError(t, err) assert.Equal(t, cty.EmptyObjectVal, result) }) } func TestExternalTerraformOutputJSONToCtyValueMap(t *testing.T) { t.Parallel() jsonOutput := []byte(`{ "vpc_id": { "sensitive": false, "type": "string", "value": "vpc-abc123" }, "instance_count": { "sensitive": false, "type": "number", "value": 3 } }`) result, err := config.TerraformOutputJSONToCtyValueMap("test-config", jsonOutput) require.NoError(t, err) assert.Len(t, result, 2) vpcID := result["vpc_id"] assert.Equal(t, cty.String, vpcID.Type()) assert.Equal(t, "vpc-abc123", vpcID.AsString()) } func TestExternalGetUnitDir(t *testing.T) { t.Parallel() t.Run("with stack dir", func(t *testing.T) { t.Parallel() unit := &config.Unit{ Name: "app", Source: "./modules/app", Path: "app", } dir := config.GetUnitDir("/project", unit) assert.Equal(t, filepath.Join("/project", config.StackDir, "app"), dir) }) t.Run("no stack dir", func(t *testing.T) { t.Parallel() noStack := true unit := &config.Unit{ Name: "app", Source: "./modules/app", Path: "app", NoStack: &noStack, } dir := config.GetUnitDir("/project", unit) assert.Equal(t, filepath.Join("/project", "app"), dir) }) } func TestExternalStackTypes(t *testing.T) { t.Parallel() t.Run("StackConfig", func(t *testing.T) { t.Parallel() sc := &config.StackConfig{ Units: []*config.Unit{ {Name: "web", Source: "./web", Path: "web"}, }, Stacks: []*config.Stack{ {Name: "infra", Source: "./infra", Path: "infra"}, }, } assert.Len(t, sc.Units, 1) assert.Len(t, sc.Stacks, 1) assert.Equal(t, "web", sc.Units[0].Name) assert.Equal(t, "infra", sc.Stacks[0].Name) }) t.Run("Unit fields", func(t *testing.T) { t.Parallel() noValidation := true unit := &config.Unit{ Name: "db", Path: "database", NoValidation: &noValidation, } assert.Equal(t, "db", unit.Name) assert.Equal(t, "database", unit.Path) assert.True(t, *unit.NoValidation) }) t.Run("Stack fields", func(t *testing.T) { t.Parallel() noStack := false stack := &config.Stack{ Name: "networking", Path: "net", NoStack: &noStack, } assert.Equal(t, "networking", stack.Name) assert.Equal(t, "net", stack.Path) assert.False(t, *stack.NoStack) }) } func TestExternalHclparse(t *testing.T) { t.Parallel() l := createExternalLogger() parser := hclparse.NewParser(hclparse.WithLogger(l)) hclContent := ` name = "test" count = 42 ` file, err := parser.ParseFromString(hclContent, "test.hcl") require.NoError(t, err) require.NotNil(t, file) attrs, err := file.JustAttributes() require.NoError(t, err) attrNames := make([]string, 0, len(attrs)) for _, attr := range attrs { attrNames = append(attrNames, attr.Name) } assert.Contains(t, attrNames, "name") assert.Contains(t, attrNames, "count") } func TestExternalModuleDependencies(t *testing.T) { t.Parallel() t.Run("create and read", func(t *testing.T) { t.Parallel() deps := &config.ModuleDependencies{ Paths: []string{"../vpc", "../rds"}, } assert.Len(t, deps.Paths, 2) assert.Equal(t, "../vpc", deps.Paths[0]) }) t.Run("merge", func(t *testing.T) { t.Parallel() deps := &config.ModuleDependencies{ Paths: []string{"../vpc"}, } other := &config.ModuleDependencies{ Paths: []string{"../rds", "../vpc"}, } deps.Merge(other) assert.Len(t, deps.Paths, 2) assert.Contains(t, deps.Paths, "../vpc") assert.Contains(t, deps.Paths, "../rds") }) t.Run("merge nil", func(t *testing.T) { t.Parallel() deps := &config.ModuleDependencies{ Paths: []string{"../vpc"}, } deps.Merge(nil) assert.Len(t, deps.Paths, 1) }) } func TestExternalGetDefaultConfigPath(t *testing.T) { t.Parallel() // When given a non-existent directory, GetDefaultConfigPath returns a path // ending with the default config file name. result := config.GetDefaultConfigPath("/some/nonexistent/path") assert.Contains(t, result, "terragrunt.hcl") } func TestExternalParseAndDecodeVarFile(t *testing.T) { t.Parallel() l := createExternalLogger() varFileContent := []byte(` region = "us-east-1" enabled = true `) var out map[string]any err := config.ParseAndDecodeVarFile(l, "test.hcl", varFileContent, &out) require.NoError(t, err) assert.Contains(t, out, "region") assert.Contains(t, out, "enabled") assert.Equal(t, "us-east-1", out["region"]) assert.Equal(t, true, out["enabled"]) } // TestExternalParseConfigStringNoCommand validates that an external consumer can // parse a Terragrunt config using NewParsingContext with zero options (no // internal/ imports required). This previously caused a nil pointer dereference // when TerraformCliArgs was nil. func TestExternalParseConfigStringNoCommand(t *testing.T) { t.Parallel() l := createExternalLogger() hclConfig := ` inputs = { env = "dev" } ` ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) cfg, err := config.ParseConfigString(ctx, pctx, l, config.DefaultTerragruntConfigPath, hclConfig, nil) require.NoError(t, err) require.NotNil(t, cfg) assert.Equal(t, "dev", cfg.Inputs["env"]) } // TestExternalParseStackConfigString validates that an external consumer can // parse a terragrunt.stack.hcl config using NewParsingContext with zero options // and no internal/ imports. func TestExternalParseStackConfigString(t *testing.T) { t.Parallel() l := createExternalLogger() stackHCL := ` unit "app" { source = "./modules/app" path = "app" } unit "db" { source = "./modules/db" path = "database" } ` ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) v := cty.ObjectVal(map[string]cty.Value{}) sc, err := config.ReadStackConfigString( ctx, l, pctx, config.DefaultStackFile, stackHCL, &v, ) require.NoError(t, err) require.NotNil(t, sc) require.Len(t, sc.Units, 2) assert.Equal(t, "app", sc.Units[0].Name) assert.Equal(t, "./modules/app", sc.Units[0].Source) assert.Equal(t, "app", sc.Units[0].Path) assert.Equal(t, "db", sc.Units[1].Name) assert.Equal(t, "database", sc.Units[1].Path) } // TestExternalParseStackConfigStringNilValues validates that an external consumer can // parse a terragrunt.stack.hcl config using NewParsingContext with nil for values. func TestExternalParseStackConfigStringNilValues(t *testing.T) { t.Parallel() l := createExternalLogger() stackHCL := ` unit "app" { source = "./modules/app" path = "app" } unit "db" { source = "./modules/db" path = "database" } ` ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) sc, err := config.ReadStackConfigString( ctx, l, pctx, config.DefaultStackFile, stackHCL, nil, ) require.NoError(t, err) require.NotNil(t, sc) require.Len(t, sc.Units, 2) assert.Equal(t, "app", sc.Units[0].Name) assert.Equal(t, "./modules/app", sc.Units[0].Source) assert.Equal(t, "app", sc.Units[0].Path) assert.Equal(t, "db", sc.Units[1].Name) assert.Equal(t, "database", sc.Units[1].Path) } // TestExternalParseStackConfigValidValues validates that an external consumer can // parse a terragrunt.stack.hcl config using NewParsingContext with valid values. func TestExternalParseStackConfigStringValidValues(t *testing.T) { t.Parallel() l := createExternalLogger() stackHCL := ` unit "app" { source = "./modules/app" path = values.app_path } unit "db" { source = "./modules/db" path = "database" } ` ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) v := cty.ObjectVal(map[string]cty.Value{ "app_path": cty.StringVal("foo"), }) sc, err := config.ReadStackConfigString( ctx, l, pctx, config.DefaultStackFile, stackHCL, &v, ) require.NoError(t, err) require.NotNil(t, sc) require.Len(t, sc.Units, 2) assert.Equal(t, "app", sc.Units[0].Name) assert.Equal(t, "./modules/app", sc.Units[0].Source) assert.Equal(t, "foo", sc.Units[0].Path) assert.Equal(t, "db", sc.Units[1].Name) assert.Equal(t, "database", sc.Units[1].Path) } // TestExternalReadValuesAndParseStackConfig validates that an external consumer // can read a terragrunt.values.hcl file from disk using ReadValues and feed the // result into ReadStackConfigString — no internal/ imports required. func TestExternalReadValuesAndParseStackConfig(t *testing.T) { t.Parallel() l := createExternalLogger() // Write a terragrunt.values.hcl file to a temp directory. dir := t.TempDir() valuesContent := []byte(` app_path = "my-app" region = "us-west-2" `) require.NoError(t, os.WriteFile(filepath.Join(dir, "terragrunt.values.hcl"), valuesContent, 0644)) ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) // Read values from the file on disk. values, err := config.ReadValues(ctx, pctx, l, dir) require.NoError(t, err) require.NotNil(t, values) // Parse a stack config that references the values. stackHCL := ` unit "app" { source = "./modules/app" path = values.app_path } ` sc, err := config.ReadStackConfigString(ctx, l, pctx, config.DefaultStackFile, stackHCL, values) require.NoError(t, err) require.NotNil(t, sc) require.Len(t, sc.Units, 1) assert.Equal(t, "app", sc.Units[0].Name) assert.Equal(t, "my-app", sc.Units[0].Path) } // TestExternalReadValuesAndParseConfig validates that an external consumer can // parse a regular terragrunt.hcl that references values.* when a // terragrunt.values.hcl file sits next to it — no internal/ imports required. // // ParseConfig automatically calls ReadValues from the config file's directory, // so the configPath argument must point into the directory containing the // values file. func TestExternalReadValuesAndParseConfig(t *testing.T) { t.Parallel() l := createExternalLogger() // Write a terragrunt.values.hcl file to a temp directory. dir := t.TempDir() valuesContent := []byte(` env = "staging" region = "eu-west-1" `) require.NoError(t, os.WriteFile(filepath.Join(dir, "terragrunt.values.hcl"), valuesContent, 0644)) ctx := t.Context() ctx, pctx := config.NewParsingContext(ctx, l) // Use a configPath inside the temp dir so ParseConfig discovers the // adjacent terragrunt.values.hcl automatically. configPath := filepath.Join(dir, config.DefaultTerragruntConfigPath) hclConfig := ` inputs = { env = values.env region = values.region } ` cfg, err := config.ParseConfigString(ctx, pctx, l, configPath, hclConfig, nil) require.NoError(t, err) require.NotNil(t, cfg) assert.Equal(t, "staging", cfg.Inputs["env"]) assert.Equal(t, "eu-west-1", cfg.Inputs["region"]) } ================================================ FILE: pkg/config/feature_flag.go ================================================ package config import ( "fmt" "strconv" "github.com/pkg/errors" "github.com/zclconf/go-cty/cty" ) // FeatureFlags represents a list of feature flags. type FeatureFlags []*FeatureFlag // FeatureFlag feature flags struct. type FeatureFlag struct { Default *cty.Value `cty:"default" hcl:"default,attr"` Name string `cty:"name" hcl:",label"` } // ctyFeatureFlag struct used to pass FeatureFlag to cty.Value. type ctyFeatureFlag struct { Value cty.Value `cty:"value"` Name string `cty:"name"` } // DeepMerge merges the source FeatureFlag into the target FeatureFlag. func (feature *FeatureFlag) DeepMerge(source *FeatureFlag) error { if source.Name != "" { feature.Name = source.Name } if source.Default == nil { feature.Default = source.Default } else { updatedDefaults, err := deepMergeCtyMaps(*feature.Default, *source.Default) if err != nil { return err } feature.Default = updatedDefaults } return nil } // DeepMerge feature flags. func deepMergeFeatureBlocks(targetFeatureFlags []*FeatureFlag, sourceFeatureFlags []*FeatureFlag) ([]*FeatureFlag, error) { if sourceFeatureFlags == nil && targetFeatureFlags == nil { return nil, nil } keys := make([]string, 0, len(targetFeatureFlags)) featureBlocks := make(map[string]*FeatureFlag) for _, flag := range targetFeatureFlags { featureBlocks[flag.Name] = flag keys = append(keys, flag.Name) } for _, flag := range sourceFeatureFlags { sameKeyDep, hasSameKey := featureBlocks[flag.Name] if hasSameKey { sameKeyFlagPtr := sameKeyDep if err := sameKeyFlagPtr.DeepMerge(flag); err != nil { return nil, err } featureBlocks[flag.Name] = sameKeyFlagPtr } else { featureBlocks[flag.Name] = flag keys = append(keys, flag.Name) } } combinedFlags := make([]*FeatureFlag, 0, len(keys)) for _, key := range keys { combinedFlags = append(combinedFlags, featureBlocks[key]) } return combinedFlags, nil } // DefaultAsString returns the default value of the feature flag as a string. func (feature *FeatureFlag) DefaultAsString() (string, error) { if feature.Default == nil { return "", nil } if feature.Default.Type() == cty.String { return feature.Default.AsString(), nil } return CtyValueAsString(*feature.Default) } // Convert generic flag value to cty.Value. func flagToCtyValue(name string, value any) (cty.Value, error) { ctyValue, err := GoTypeToCty(value) if err != nil { return cty.NilVal, err } ctyFlag := ctyFeatureFlag{ Name: name, Value: ctyValue, } return GoTypeToCty(ctyFlag) } // Convert a flag to a cty.Value using the provided cty.Type. func flagToTypedCtyValue(name string, ctyType cty.Type, value any) (cty.Value, error) { var flagValue = value if ctyType == cty.Bool { // convert value to boolean even if it is string parsedValue, err := strconv.ParseBool(fmt.Sprintf("%v", flagValue)) if err != nil { return cty.NilVal, errors.WithStack(err) } flagValue = parsedValue } ctyOut, err := GoTypeToCty(flagValue) if err != nil { return cty.NilVal, errors.WithStack(err) } ctyFlag := ctyFeatureFlag{ Name: name, Value: ctyOut, } return GoTypeToCty(ctyFlag) } ================================================ FILE: pkg/config/hclparse/attributes.go ================================================ package hclparse import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" ) type Attributes []*Attribute func NewAttributes(file *File, hclAttrs hcl.Attributes) Attributes { var attrs = make(Attributes, 0, len(hclAttrs)) for _, hclAttr := range hclAttrs { attrs = append(attrs, &Attribute{ File: file, Attribute: hclAttr, }) } return attrs } func (attrs Attributes) ValidateIdentifier() error { for _, attr := range attrs { if err := attr.ValidateIdentifier(); err != nil { // TODO: Remove lint suppression return nil //nolint:nilerr } } return nil } func (attrs Attributes) Range() hcl.Range { var rng hcl.Range for _, attr := range attrs { rng.Filename = attr.Range.Filename if rng.Start.Line > attr.Range.Start.Line || rng.Start.Column > attr.Range.Start.Column { rng.Start = attr.Range.Start } if rng.End.Line < attr.Range.End.Line || rng.End.Column < attr.Range.End.Column { rng.End = attr.Range.End } } return rng } type Attribute struct { *File *hcl.Attribute } func (attr *Attribute) ValidateIdentifier() error { if !hclsyntax.ValidIdentifier(attr.Name) { diags := hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: "Invalid value name", Detail: badIdentifierDetail, Subject: &attr.NameRange, }} if err := attr.HandleDiagnostics(diags); err != nil { return errors.New(err) } } return nil } func (attr *Attribute) Value(evalCtx *hcl.EvalContext) (cty.Value, error) { evaluatedVal, diags := attr.Expr.Value(evalCtx) if err := attr.HandleDiagnostics(diags); err != nil { return evaluatedVal, errors.New(err) } return evaluatedVal, nil } ================================================ FILE: pkg/config/hclparse/block.go ================================================ package hclparse import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/hcl/v2" ) // Detailed error messages in diagnostics returned by parsing locals const ( // A consistent detail message for all "not a valid identifier" diagnostics. This is exactly the same as that returned // by terraform. badIdentifierDetail = "A name must start with a letter and may contain only letters, digits, underscores, and dashes." ) type Block struct { *File *hcl.Block } // JustAttributes loads the block into name expression pairs to assist with evaluation of the attrs prior to // evaluating the whole config. Note that this is exactly the same as // terraform/configs/named_values.go:decodeLocalsBlock func (block *Block) JustAttributes() (Attributes, error) { hclAttrs, diags := block.Body.JustAttributes() if err := block.HandleDiagnostics(diags); err != nil { return nil, errors.New(err) } attrs := NewAttributes(block.File, hclAttrs) if err := attrs.ValidateIdentifier(); err != nil { return nil, err } return attrs, nil } ================================================ FILE: pkg/config/hclparse/errors.go ================================================ package hclparse import ( "fmt" "reflect" ) type PanicWhileParsingConfigError struct { RecoveredValue any ConfigFile string } func (err PanicWhileParsingConfigError) Error() string { return fmt.Sprintf("Recovering panic while parsing '%s'. Got error of type '%v': %v", err.ConfigFile, reflect.TypeOf(err.RecoveredValue), err.RecoveredValue) } ================================================ FILE: pkg/config/hclparse/file.go ================================================ package hclparse import ( "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" ) const ( // A consistent error message for multiple catalog block in terragrunt config (which is currently not supported) multipleBlockDetailFmt = "Terragrunt currently does not support multiple %[1]s blocks in a single config. Consolidate to a single %[1]s block." ) type File struct { *Parser *hcl.File ConfigPath string } func (file *File) Content() string { return string(file.Bytes) } // Update reparses the file with the new `content`. func (file *File) Update(content []byte) error { // Since `hclparse.Parser` has a cache, we need to recreate(clone) the Parser instance without current file // to be able to parse the configuration with the same `configPath`. parser := hclparse.NewParser() for configPath, copyfile := range file.Files() { if configPath != file.ConfigPath { parser.AddFile(configPath, copyfile) } } file.Parser.Parser = parser // we need to reparse the new updated contents. This is necessarily because the blocks // returned by hclparse does not support editing, and so we have to go through hclwrite, which leads to a // different AST representation. updatedFile, err := file.ParseFromBytes(content, file.ConfigPath) if err != nil { return err } file.File = updatedFile.File return nil } // Decode uses the HCL2 parser to decode the parsed HCL into the struct specified by out. // // Note that we take a two pass approach to support parsing include blocks without a label. Ideally we can parse include // blocks with and without labels in a single pass, but the HCL parser is fairly restrictive when it comes to parsing // blocks with labels, requiring the exact number of expected labels in the parsing step. To handle this restriction, // we first see if there are any include blocks without any labels, and if there is, we modify it in the file object to // inject the label as "". func (file *File) Decode(out any, evalContext *hcl.EvalContext) (err error) { if file.fileUpdateHandlerFunc != nil { if err := file.fileUpdateHandlerFunc(file); err != nil { return err } } diags := gohcl.DecodeBody(file.Body, evalContext, out) if err := file.HandleDiagnostics(diags); err != nil { return errors.New(err) } return nil } // Blocks takes a parsed HCL file and extracts a reference to the `name` block, if there are defined. func (file *File) Blocks(name string, isMultipleAllowed bool) ([]*Block, error) { catalogSchema := &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ {Type: name}, }, } // We use PartialContent here, because we are only interested in parsing out the catalog block. parsed, _, diags := file.Body.PartialContent(catalogSchema) if err := file.HandleDiagnostics(diags); err != nil { return nil, errors.New(err) } extractedBlocks := []*Block{} for _, block := range parsed.Blocks { if block.Type == name { extractedBlocks = append(extractedBlocks, &Block{ File: file, Block: block, }) } } if len(extractedBlocks) > 1 && !isMultipleAllowed { return nil, errors.New( &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: fmt.Sprintf("Multiple %s block", name), Detail: fmt.Sprintf(multipleBlockDetailFmt, name), }, ) } return extractedBlocks, nil } func (file *File) JustAttributes() (Attributes, error) { hclAttrs, diags := file.Body.JustAttributes() if err := file.HandleDiagnostics(diags); err != nil { return nil, errors.New(err) } attrs := NewAttributes(file, hclAttrs) if err := attrs.ValidateIdentifier(); err != nil { return nil, err } return attrs, nil } func (file *File) HandleDiagnostics(diags hcl.Diagnostics) error { return file.handleDiagnostics(file, diags) } ================================================ FILE: pkg/config/hclparse/options.go ================================================ package hclparse import ( "io" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/hcl/v2" ) type Option func(*Parser) *Parser func WithLogger(logger log.Logger) Option { return func(parser *Parser) *Parser { parser.logger = logger return parser } } func WithDiagnosticsWriter(writer io.Writer, disableColor bool) Option { return func(parser *Parser) *Parser { diagsWriter := parser.GetDiagnosticsWriter(writer, disableColor) parser.diagsWriterFunc = func(diags hcl.Diagnostics) error { if !diags.HasErrors() { return nil } if err := diagsWriter.WriteDiagnostics(diags); err != nil { return errors.New(err) } return nil } return parser } } // WithFileUpdate sets the `fileUpdateHandlerFunc` func which is run before each file decoding. func WithFileUpdate(fn func(*File) error) Option { return func(parser *Parser) *Parser { parser.fileUpdateHandlerFunc = fn return parser } } // WithHaltOnErrorOnlyForBlocks configures a diagnostic error handler that runs when diagnostic errors occur. // If errors occur in the given `blockNames` blocks, parser returns the error to its caller, otherwise it skips the error. func WithHaltOnErrorOnlyForBlocks(blockNames []string) Option { return func(parser *Parser) *Parser { parser.handleDiagnosticsFunc = appendHandleDiagnosticsFunc(parser.handleDiagnosticsFunc, func(file *File, diags hcl.Diagnostics) (hcl.Diagnostics, error) { if file == nil || !diags.HasErrors() { return diags, nil } for _, blockName := range blockNames { blocks, err := file.Blocks(blockName, true) if err != nil { return nil, err } for _, block := range blocks { blockAttrs, _ := block.Body.JustAttributes() for _, blokcAttr := range blockAttrs { for _, diag := range diags { if diag.Context != nil && blokcAttr.Range.Overlaps(*diag.Context) { return diags, nil } } } } } return nil, nil }) return parser } } func WithDiagnosticsHandler(fn func(file *hcl.File, diags hcl.Diagnostics) (hcl.Diagnostics, error)) Option { return func(parser *Parser) *Parser { parser.handleDiagnosticsFunc = appendHandleDiagnosticsFunc(parser.handleDiagnosticsFunc, func(file *File, diags hcl.Diagnostics) (hcl.Diagnostics, error) { return fn(file.File, diags) }) return parser } } func appendHandleDiagnosticsFunc(prev, next func(*File, hcl.Diagnostics) (hcl.Diagnostics, error)) func(*File, hcl.Diagnostics) (hcl.Diagnostics, error) { return func(file *File, diags hcl.Diagnostics) (hcl.Diagnostics, error) { var err error if prev != nil { if diags, err = prev(file, diags); err != nil { return diags, err } } return next(file, diags) } } ================================================ FILE: pkg/config/hclparse/parser.go ================================================ // Package hclparse provides a wrapper around the HCL2 parser to handle diagnostics and errors in a more user-friendly way. // // The package wraps `hclparse.Parser` to be able to handle diagnostic errors from one place, see `handleDiagnostics(diags hcl.Diagnostics) error` func. // This allows us to halt the process only when certain errors occur, such as skipping all errors not related to the `catalog` block. package hclparse import ( "io" "os" "path/filepath" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "golang.org/x/term" ) type Parser struct { *hclparse.Parser diagsWriterFunc func(hcl.Diagnostics) error handleDiagnosticsFunc func(*File, hcl.Diagnostics) (hcl.Diagnostics, error) fileUpdateHandlerFunc func(*File) error logger log.Logger } func NewParser(opts ...Option) *Parser { return (&Parser{ Parser: hclparse.NewParser(), logger: log.Default(), }).withOptions(opts...) } func (parser *Parser) withOptions(opts ...Option) *Parser { for _, opt := range opts { parser = opt(parser) } return parser } func (parser *Parser) ParseFromFile(configPath string) (*File, error) { content, err := os.ReadFile(configPath) if err != nil { parser.logger.Warnf("Error reading file %s: %v", configPath, err) return nil, errors.New(err) } return parser.ParseFromBytes(content, configPath) } // ParseFromString uses the HCL2 parser to parse the given string into an HCL file body. func (parser *Parser) ParseFromString(content, configPath string) (file *File, err error) { return parser.ParseFromBytes([]byte(content), configPath) } func (parser *Parser) ParseFromBytes(content []byte, configPath string) (file *File, err error) { // The HCL2 parser and especially cty conversions will panic in many types of errors, so we have to recover from // those panics here and convert them to normal errors defer func() { if recovered := recover(); recovered != nil { err = errors.New(PanicWhileParsingConfigError{RecoveredValue: recovered, ConfigFile: configPath}) } }() var ( diags hcl.Diagnostics hclFile *hcl.File ) switch filepath.Ext(configPath) { case ".json": hclFile, diags = parser.ParseJSON(content, configPath) default: hclFile, diags = parser.ParseHCL(content, configPath) } file = &File{ Parser: parser, File: hclFile, ConfigPath: configPath, } if err := parser.handleDiagnostics(file, diags); err != nil { parser.logger.Warnf("Failed to parse HCL in file %s: %v", configPath, diags) return nil, errors.New(diags) } return file, nil } // GetDiagnosticsWriter returns a hcl2 parsing diagnostics emitter for the current terminal. func (parser *Parser) GetDiagnosticsWriter(writer io.Writer, disableColor bool) hcl.DiagnosticWriter { termColor := !disableColor && term.IsTerminal(int(os.Stderr.Fd())) termWidth, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { // When not connected to a terminal (e.g., in CI, tests, or piped output), // use width 0 to disable word-wrapping. This prevents error messages from // being split at unpredictable positions based on path lengths, which can // cause issues when parsing or testing error output. termWidth = 0 } return hcl.NewDiagnosticTextWriter(writer, parser.Files(), uint(termWidth), termColor) } func (parser *Parser) handleDiagnostics(file *File, diags hcl.Diagnostics) error { if len(diags) == 0 { return nil } if fn := parser.handleDiagnosticsFunc; fn != nil { var err error if diags, err = fn(file, diags); err != nil || diags == nil { return err } } if fn := parser.diagsWriterFunc; fn != nil { if err := fn(diags); err != nil { return err } } return diags } ================================================ FILE: pkg/config/include.go ================================================ package config import ( "context" "encoding/json" "fmt" "path/filepath" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "dario.cat/mergo" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclwrite" "maps" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" ) const bareIncludeKey = "" var fieldsCopyLocks = util.NewKeyLocks() // Parse the config of the given include, if one is specified func parseIncludedConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, includedConfig *IncludeConfig) (*TerragruntConfig, error) { if includedConfig.Path == "" { return nil, errors.New(IncludedConfigMissingPathError(pctx.TerragruntConfigPath)) } includePath := includedConfig.Path if !filepath.IsAbs(includePath) { includePath = filepath.Join(filepath.Dir(pctx.TerragruntConfigPath), includePath) } // These condition are here to specifically handle the `run --all` command. During any `run --all` call, terragrunt // needs to first build up the dependency graph to know what order to process the modules in. We want to limit users // from creating a dependency between the dependency path for graph generation, and a module output. This is because // the outputs may not be available yet during the graph generation. E.g., consider a completely new deployment and // `terragrunt run --all apply` is called. In this case, the outputs are expected to be materialized while terragrunt // is running `apply` through the graph, but NOT when the dependency graph is first being formulated. // // To support this, we implement the following conditions for when terragrunt can fully parse the included config // (only one needs to be true): // - Included config does NOT have a dependency block. // - Terragrunt is NOT performing a partial parse (which indicates whether or not Terragrunt is building a module // graph). // // These conditions are sufficient to avoid a situation where dependency block parsing relies on output fetching. // Note that the user does not have to have a dynamic dependency path that directly depends on dependency outputs to // cause this! For example, suppose the user has a dependency path that depends on an included input: // // include "root" { // path = find_in_parent_folders("root.hcl") // expose = true // } // dependency "dep" { // config_path = include.root.inputs.vpc_dir // } // // In this example, the user the vpc_dir input may not directly depend on a dependency. However, what if the root // config had other inputs that depended on a dependency? E.g.: // // inputs = { // vpc_dir = "../vpc" // vpc_id = dependency.vpc.outputs.id // } // // In this situation, terragrunt can not parse the included inputs attribute unless it fetches the `vpc` dependency // outputs. Since the block parsing is transitive, it leads to a situation where terragrunt cannot parse the `dep` // dependency block unless the `vpc` dependency has outputs (since we can't partially parse the `inputs` attribute). // OTOH, if we know the included config has no `dependency` defined, then no matter what attribute is pulled in, we // know that the `dependency` block path will never depend on dependency outputs. Hence, we perform a full // parse of the included config in the graph generation stage only if the included config does NOT have a dependency // block, but resort to a partial parse otherwise. // // NOTE: To make the logic easier to implement, we implement the inverse here, where we check whether the included // config has a dependency block, and if we are in the middle of a partial parse, we perform a partial parse of the // included config. hasDependency, err := configFileHasDependencyBlock(includePath) if err != nil { return nil, err } if hasDependency && len(pctx.PartialParseDecodeList) > 0 { l.Debugf( "Included config %s can only be partially parsed during dependency graph formation for run --all command as it has a dependency block.", includePath, ) return PartialParseConfigFile(ctx, pctx, l, includePath, includedConfig) } // When included config has dependencies, suppress diagnostics during parsing. parseCtx := pctx if hasDependency { parseCtx = pctx.WithDiagnosticsSuppressed(l) } config, err := ParseConfigFile(ctx, parseCtx, l, includePath, includedConfig) if err != nil { var configNotFoundError TerragruntConfigNotFoundError if errors.As(err, &configNotFoundError) { return nil, IncludeConfigNotFoundError{ IncludePath: includePath, SourcePath: pctx.TerragruntConfigPath, } } return nil, err } return config, nil } // handleInclude merges the included config into the current config depending on the merge strategy specified by the // user. func handleInclude(ctx context.Context, pctx *ParsingContext, l log.Logger, config *TerragruntConfig, isPartial bool) (*TerragruntConfig, error) { if pctx.TrackInclude == nil { return nil, errors.New("you reached an impossible condition. This is most likely a bug in terragrunt. Please open an issue at github.com/gruntwork-io/terragrunt with this error message.Code: HANDLE_INCLUDE_NIL_INCLUDE_CONFIG") } // We merge in the include blocks in reverse order here. The expectation is that the bottom most elements override // those in earlier includes, so we need to merge bottom up instead of top down to ensure this. includeList := pctx.TrackInclude.CurrentList baseConfig := config for i := len(includeList) - 1; i >= 0; i-- { includeConfig := includeList[i] mergeStrategy, err := includeConfig.GetMergeStrategy() if err != nil { return config, err } var ( parsedIncludeConfig *TerragruntConfig logPrefix string ) trackedIncludePath := includeConfig.Path if !filepath.IsAbs(trackedIncludePath) { trackedIncludePath = filepath.Clean(filepath.Join(filepath.Dir(pctx.TerragruntConfigPath), trackedIncludePath)) } trackFileRead(pctx.FilesRead, trackedIncludePath) if isPartial { parsedIncludeConfig, err = partialParseIncludedConfig(ctx, pctx, l, &includeConfig) logPrefix = "[Partial] " } else { parsedIncludeConfig, err = parseIncludedConfig(ctx, pctx, l, &includeConfig) } if err != nil { return baseConfig, err } // TODO: Remove lint suppression switch mergeStrategy { //nolint:exhaustive case NoMerge: l.Debugf("%sIncluded config %s has strategy no merge: not merging config in.", logPrefix, includeConfig.Path) case ShallowMerge: l.Debugf("%sIncluded config %s has strategy shallow merge: merging config in (shallow).", logPrefix, includeConfig.Path) if err := parsedIncludeConfig.Merge(l, baseConfig); err != nil { return nil, err } baseConfig = parsedIncludeConfig case DeepMerge: l.Debugf("%sIncluded config %s has strategy deep merge: merging config in (deep).", logPrefix, includeConfig.Path) if err := parsedIncludeConfig.DeepMerge(l, baseConfig); err != nil { return nil, err } baseConfig = parsedIncludeConfig default: return nil, fmt.Errorf("you reached an impossible condition. This is most likely a bug in terragrunt. Please open an issue at github.com/gruntwork-io/terragrunt with this error message. Code: UNKNOWN_MERGE_STRATEGY_%s", mergeStrategy) } } return baseConfig, nil } // handleIncludeForDependency is a partial merge of the included config to handle dependencies. This only merges the // dependency block configurations between the included config and the child config. This allows us to merge the two // dependencies prior to retrieving the outputs, allowing you to have partial configuration that is overridden by a // child. func handleIncludeForDependency(ctx context.Context, pctx *ParsingContext, l log.Logger, childDecodedDependency TerragruntDependency) (*TerragruntDependency, error) { if pctx.TrackInclude == nil { return nil, errors.New("you reached an impossible condition. This is most likely a bug in terragrunt. Please open an issue at github.com/gruntwork-io/terragrunt with this error message. Code: HANDLE_INCLUDE_DEPENDENCY_NIL_INCLUDE_CONFIG") } // We merge in the include blocks in reverse order here. The expectation is that the bottom most elements override // those in earlier includes, so we need to merge bottom up instead of top down to ensure this. includeList := pctx.TrackInclude.CurrentList baseDependencyBlock := childDecodedDependency.Dependencies for i := len(includeList) - 1; i >= 0; i-- { includeConfig := includeList[i] mergeStrategy, err := includeConfig.GetMergeStrategy() if err != nil { return nil, err } includedPartialParse, err := partialParseIncludedConfig( ctx, pctx.WithDecodeList(DependencyBlock, FeatureFlagsBlock, ExcludeBlock, ErrorsBlock), l, &includeConfig) if err != nil { return nil, err } // TODO: Remove lint suppression switch mergeStrategy { //nolint:exhaustive case NoMerge: l.Debugf( "Included config %s has strategy no merge: not merging config in for dependency.", util.RelPathForLog( pctx.RootWorkingDir, includeConfig.Path, pctx.Writers.LogShowAbsPaths, ), ) case ShallowMerge: l.Debugf( "Included config %s has strategy shallow merge: merging config in (shallow) for dependency.", util.RelPathForLog( pctx.RootWorkingDir, includeConfig.Path, pctx.Writers.LogShowAbsPaths, ), ) mergedDependencyBlock := mergeDependencyBlocks(includedPartialParse.TerragruntDependencies, baseDependencyBlock) baseDependencyBlock = mergedDependencyBlock case DeepMerge: l.Debugf( "Included config %s has strategy deep merge: merging config in (deep) for dependency.", util.RelPathForLog( pctx.RootWorkingDir, includeConfig.Path, pctx.Writers.LogShowAbsPaths, ), ) mergedDependencyBlock, err := deepMergeDependencyBlocks(includedPartialParse.TerragruntDependencies, baseDependencyBlock) if err != nil { return nil, err } baseDependencyBlock = mergedDependencyBlock default: return nil, fmt.Errorf( "you reached an impossible condition. This is most likely a bug in terragrunt. "+ "Please open an issue at github.com/gruntwork-io/terragrunt with this error message. "+ "Code: UNKNOWN_MERGE_STRATEGY_%s_DEPENDENCY", mergeStrategy, ) } } return &TerragruntDependency{Dependencies: baseDependencyBlock}, nil } // Merge performs a shallow merge of the given sourceConfig into the targetConfig. sourceConfig will override common // attributes defined in the targetConfig. Note that this will modify the targetConfig. // NOTE: the following attributes are deliberately omitted from the merge operation, as they are handled differently in // the parser: // - locals [These blocks are not merged by design] // // NOTE: dependencies block is a special case and is merged deeply. This is necessary to ensure the configstack system // works correctly, as it uses the `Dependencies` list to track the dependencies of modules for graph building purposes. // This list includes the dependencies added from dependency blocks, which is handled in a different stage. func (cfg *TerragruntConfig) Merge(l log.Logger, sourceConfig *TerragruntConfig) error { // Merge simple attributes first if sourceConfig.DownloadDir != "" { cfg.DownloadDir = sourceConfig.DownloadDir } if sourceConfig.IamRole != "" { cfg.IamRole = sourceConfig.IamRole } if sourceConfig.IamAssumeRoleDuration != nil { cfg.IamAssumeRoleDuration = sourceConfig.IamAssumeRoleDuration } if sourceConfig.IamAssumeRoleSessionName != "" { cfg.IamAssumeRoleSessionName = sourceConfig.IamAssumeRoleSessionName } if sourceConfig.IamWebIdentityToken != "" { cfg.IamWebIdentityToken = sourceConfig.IamWebIdentityToken } if sourceConfig.TerraformVersionConstraint != "" { cfg.TerraformVersionConstraint = sourceConfig.TerraformVersionConstraint } if sourceConfig.TerraformBinary != "" { cfg.TerraformBinary = sourceConfig.TerraformBinary } if sourceConfig.PreventDestroy != nil { cfg.PreventDestroy = sourceConfig.PreventDestroy } if sourceConfig.TerragruntVersionConstraint != "" { cfg.TerragruntVersionConstraint = sourceConfig.TerragruntVersionConstraint } if sourceConfig.Engine != nil { cfg.Engine = sourceConfig.Engine.Clone() } if sourceConfig.Exclude != nil { cfg.Exclude = sourceConfig.Exclude.Clone() } if sourceConfig.Errors != nil { cfg.Errors = sourceConfig.Errors.Clone() } if sourceConfig.RemoteState != nil { cfg.RemoteState = sourceConfig.RemoteState } if sourceConfig.Terraform != nil { if cfg.Terraform == nil { cfg.Terraform = sourceConfig.Terraform } else { if sourceConfig.Terraform.Source != nil { cfg.Terraform.Source = sourceConfig.Terraform.Source } if sourceConfig.Terraform.CopyTerraformLockFile != nil { cfg.Terraform.CopyTerraformLockFile = sourceConfig.Terraform.CopyTerraformLockFile } mergeExtraArgs(l, sourceConfig.Terraform.ExtraArgs, &cfg.Terraform.ExtraArgs) mergeHooks(l, sourceConfig.Terraform.BeforeHooks, &cfg.Terraform.BeforeHooks) mergeHooks(l, sourceConfig.Terraform.AfterHooks, &cfg.Terraform.AfterHooks) mergeErrorHooks(l, sourceConfig.Terraform.ErrorHooks, &cfg.Terraform.ErrorHooks) } } // Dependency blocks are shallow merged by name cfg.TerragruntDependencies = mergeDependencyBlocks(cfg.TerragruntDependencies, sourceConfig.TerragruntDependencies) cfg.FeatureFlags = mergeFeatureFlags(cfg.FeatureFlags, sourceConfig.FeatureFlags) // Deep merge the dependencies list. This is different from dependency blocks, and refers to the deprecated // dependencies block! if sourceConfig.Dependencies != nil { if cfg.Dependencies == nil { cfg.Dependencies = sourceConfig.Dependencies } else { cfg.Dependencies.Merge(sourceConfig.Dependencies) } } // Merge the generate configs. This is a shallow merge. Meaning, if the child has the same name generate block, then the // child's generate block will override the parent's block. err := validateGenerateConfigs(&sourceConfig.GenerateConfigs, &cfg.GenerateConfigs) if err != nil { return err } maps.Copy(cfg.GenerateConfigs, sourceConfig.GenerateConfigs) if sourceConfig.Inputs != nil { cfg.Inputs = mergeInputs(sourceConfig.Inputs, cfg.Inputs) } CopyFieldsMetadata(sourceConfig, cfg) return nil } // DeepMerge performs a deep merge of the given sourceConfig into the targetConfig. Deep merge is defined as follows: // - For simple types, the source overrides the target. // - For lists, the two attribute lists are combined together in concatenation. // - For maps, the two maps are combined together recursively. That is, if the map keys overlap, then a deep merge is // performed on the map value. // - Note that some structs are not deep mergeable due to an implementation detail. This will change in the future. The // following structs have this limitation: // - remote_state // - generate // - Note that the following attributes are deliberately omitted from the merge operation, as they are handled // differently in the parser: // - dependency blocks (TerragruntDependencies) [These blocks need to retrieve outputs, so we need to merge during // the parsing step, not after the full config is decoded] // - locals [These blocks are not merged by design] func (cfg *TerragruntConfig) DeepMerge(l log.Logger, sourceConfig *TerragruntConfig) error { // Merge simple attributes first if sourceConfig.DownloadDir != "" { cfg.DownloadDir = sourceConfig.DownloadDir } if sourceConfig.IamRole != "" { cfg.IamRole = sourceConfig.IamRole } if sourceConfig.IamAssumeRoleDuration != nil { cfg.IamAssumeRoleDuration = sourceConfig.IamAssumeRoleDuration } if sourceConfig.IamAssumeRoleSessionName != "" { cfg.IamAssumeRoleSessionName = sourceConfig.IamAssumeRoleSessionName } if sourceConfig.IamWebIdentityToken != "" { cfg.IamWebIdentityToken = sourceConfig.IamWebIdentityToken } if sourceConfig.TerraformVersionConstraint != "" { cfg.TerraformVersionConstraint = sourceConfig.TerraformVersionConstraint } if sourceConfig.TerraformBinary != "" { cfg.TerraformBinary = sourceConfig.TerraformBinary } if sourceConfig.PreventDestroy != nil { cfg.PreventDestroy = sourceConfig.PreventDestroy } if sourceConfig.TerragruntVersionConstraint != "" { cfg.TerragruntVersionConstraint = sourceConfig.TerragruntVersionConstraint } if sourceConfig.Engine != nil { if cfg.Engine == nil { cfg.Engine = &EngineConfig{} } cfg.Engine.Merge(sourceConfig.Engine) } if sourceConfig.Exclude != nil { if cfg.Exclude == nil { cfg.Exclude = &ExcludeConfig{} } cfg.Exclude.Merge(sourceConfig.Exclude) } if sourceConfig.Errors != nil { if cfg.Errors == nil { cfg.Errors = &ErrorsConfig{} } cfg.Errors.Merge(sourceConfig.Errors) } // Copy only dependencies which doesn't exist in source if sourceConfig.Dependencies != nil { resultModuleDependencies := &ModuleDependencies{} if cfg.Dependencies != nil { // take in result dependencies only paths which aren't defined in source // Fix for issue: https://github.com/gruntwork-io/terragrunt/issues/1900 targetPathMap := fetchDependencyPaths(cfg) sourcePathMap := fetchDependencyPaths(sourceConfig) for key, value := range targetPathMap { _, found := sourcePathMap[key] if !found { resultModuleDependencies.Paths = append(resultModuleDependencies.Paths, value) } } // copy target paths which are defined only in Dependencies and not in TerragruntDependencies // if TerragruntDependencies will be empty, all targetConfig.Dependencies.Paths will be copied to resultModuleDependencies.Paths for _, dependencyPath := range cfg.Dependencies.Paths { var addPath = true for _, targetPath := range targetPathMap { if dependencyPath == targetPath { // path already defined in TerragruntDependencies, skip adding addPath = false break } } if addPath { resultModuleDependencies.Paths = append(resultModuleDependencies.Paths, dependencyPath) } } } resultModuleDependencies.Paths = append(resultModuleDependencies.Paths, sourceConfig.Dependencies.Paths...) cfg.Dependencies = resultModuleDependencies } // Dependency blocks are deep merged by name mergedDeps, err := deepMergeDependencyBlocks(cfg.TerragruntDependencies, sourceConfig.TerragruntDependencies) if err != nil { return err } cfg.TerragruntDependencies = mergedDeps mergedFlags, err := deepMergeFeatureBlocks(cfg.FeatureFlags, sourceConfig.FeatureFlags) if err != nil { return err } cfg.FeatureFlags = mergedFlags // Handle complex structs by recursively merging the structs together if sourceConfig.Terraform != nil { if cfg.Terraform == nil { cfg.Terraform = sourceConfig.Terraform } else { if sourceConfig.Terraform.Source != nil { cfg.Terraform.Source = sourceConfig.Terraform.Source } if sourceConfig.Terraform.CopyTerraformLockFile != nil { cfg.Terraform.CopyTerraformLockFile = sourceConfig.Terraform.CopyTerraformLockFile } if sourceConfig.Terraform.IncludeInCopy != nil { srcList := *sourceConfig.Terraform.IncludeInCopy if cfg.Terraform.IncludeInCopy != nil { targetList := *cfg.Terraform.IncludeInCopy combinedList := slices.Concat(srcList, targetList) cfg.Terraform.IncludeInCopy = &combinedList } else { cfg.Terraform.IncludeInCopy = &srcList } } if sourceConfig.Terraform.ExcludeFromCopy != nil { srcList := *sourceConfig.Terraform.ExcludeFromCopy if cfg.Terraform.ExcludeFromCopy != nil { targetList := *cfg.Terraform.ExcludeFromCopy combinedList := slices.Concat(srcList, targetList) cfg.Terraform.ExcludeFromCopy = &combinedList } else { cfg.Terraform.ExcludeFromCopy = &srcList } } mergeExtraArgs(l, sourceConfig.Terraform.ExtraArgs, &cfg.Terraform.ExtraArgs) mergeHooks(l, sourceConfig.Terraform.BeforeHooks, &cfg.Terraform.BeforeHooks) mergeHooks(l, sourceConfig.Terraform.AfterHooks, &cfg.Terraform.AfterHooks) mergeErrorHooks(l, sourceConfig.Terraform.ErrorHooks, &cfg.Terraform.ErrorHooks) } } if sourceConfig.Inputs != nil { mergedInputs, err := deepMergeInputs(sourceConfig.Inputs, cfg.Inputs) if err != nil { return err } cfg.Inputs = mergedInputs } // MAINTAINER'S NOTE: The following structs cannot be deep merged due to an implementation detail (they do not // support nil attributes, so we can't determine if an attribute was intentionally set, or was defaulted from // unspecified - this is especially problematic for bool attributes). if sourceConfig.RemoteState != nil { cfg.RemoteState = sourceConfig.RemoteState } maps.Copy(cfg.GenerateConfigs, sourceConfig.GenerateConfigs) CopyFieldsMetadata(sourceConfig, cfg) return nil } // fetchDependencyPaths - return from configuration map with dependency_name: path func fetchDependencyPaths(config *TerragruntConfig) map[string]string { var m = make(map[string]string) if config == nil { return m } for _, dependency := range config.TerragruntDependencies { m[dependency.Name] = dependency.ConfigPath.AsString() } return m } // merge feature flags by name. func mergeFeatureFlags(targetFlags []*FeatureFlag, sourceFlags []*FeatureFlag) []*FeatureFlag { if sourceFlags == nil && targetFlags == nil { return nil } keys := make([]string, 0, len(targetFlags)) flagBlocks := make(map[string]*FeatureFlag) for _, flags := range targetFlags { flagBlocks[flags.Name] = flags keys = append(keys, flags.Name) } for _, dep := range sourceFlags { _, hasSameKey := flagBlocks[dep.Name] if !hasSameKey { keys = append(keys, dep.Name) } flagBlocks[dep.Name] = dep } combinedFlags := make([]*FeatureFlag, 0, len(keys)) for _, key := range keys { combinedFlags = append(combinedFlags, flagBlocks[key]) } return combinedFlags } // Merge dependency blocks shallowly. If the source list has the same name as the target, it will override the // dependency block in the target. Otherwise, the blocks are appended. func mergeDependencyBlocks(targetDependencies []Dependency, sourceDependencies []Dependency) []Dependency { // We track the keys so that the dependencies are added in order, with those in target prepending those in // source. This is not strictly necessary, but it makes testing easier by making the output list more // predictable. keys := []string{} dependencyBlocks := make(map[string]Dependency) for _, dep := range targetDependencies { dependencyBlocks[dep.Name] = dep keys = append(keys, dep.Name) } for _, dep := range sourceDependencies { _, hasSameKey := dependencyBlocks[dep.Name] if !hasSameKey { keys = append(keys, dep.Name) } // Regardless of what is in dependencyBlocks, we will always override the key with source dependencyBlocks[dep.Name] = dep } // Now convert the map to list and set target combinedDeps := make([]Dependency, 0, len(keys)) for _, key := range keys { combinedDeps = append(combinedDeps, dependencyBlocks[key]) } return combinedDeps } // Merge dependency blocks deeply. This works almost the same as mergeDependencyBlocks, except it will recursively merge // attributes of the dependency struct if they share the same name. func deepMergeDependencyBlocks(targetDependencies []Dependency, sourceDependencies []Dependency) ([]Dependency, error) { // We track the keys so that the dependencies are added in order, with those in target prepending those in // source. This is not strictly necessary, but it makes testing easier by making the output list more // predictable. keys := []string{} dependencyBlocks := make(map[string]Dependency) for _, dep := range targetDependencies { dependencyBlocks[dep.Name] = dep keys = append(keys, dep.Name) } for _, dep := range sourceDependencies { sameKeyDep, hasSameKey := dependencyBlocks[dep.Name] if hasSameKey { sameKeyDepPtr := &sameKeyDep if err := sameKeyDepPtr.DeepMerge(&dep); err != nil { return nil, err } dependencyBlocks[dep.Name] = *sameKeyDepPtr } else { dependencyBlocks[dep.Name] = dep keys = append(keys, dep.Name) } } // Now convert the map to list and set target combinedDeps := make([]Dependency, 0, len(keys)) for _, key := range keys { combinedDeps = append(combinedDeps, dependencyBlocks[key]) } return combinedDeps, nil } // Merge the extra arguments. // // If a child's extra_arguments has the same name a parent's extra_arguments, // then the child's extra_arguments will be selected (and the parent's ignored) // If a child's extra_arguments has a different name from all of the parent's extra_arguments, // then the child's extra_arguments will be added to the end of the parents. // Therefore, terragrunt will put the child extra_arguments after the parent's // extra_arguments on the terraform cli. // Therefore, if .tfvar files from both the parent and child contain a variable // with the same name, the value from the child will win. func mergeExtraArgs(l log.Logger, childExtraArgs []TerraformExtraArguments, parentExtraArgs *[]TerraformExtraArguments) { result := *parentExtraArgs for _, child := range childExtraArgs { parentExtraArgsWithSameName := getIndexOfExtraArgsWithName(result, child.Name) if parentExtraArgsWithSameName != -1 { // If the parent contains an extra_arguments with the same name as the child, // then override the parent's extra_arguments with the child's. l.Debugf("extra_arguments '%v' from child overriding parent", child.Name) result[parentExtraArgsWithSameName] = child } else { // If the parent does not contain an extra_arguments with the same name as the child // then add the child to the end. // This ensures the child extra_arguments are added to the command line after the parent extra_arguments. result = append(result, child) } } *parentExtraArgs = result } func mergeInputs(childInputs map[string]any, parentInputs map[string]any) map[string]any { out := map[string]any{} maps.Copy(out, parentInputs) maps.Copy(out, childInputs) return out } func deepMergeInputs(childInputs map[string]any, parentInputs map[string]any) (map[string]any, error) { out := map[string]any{} maps.Copy(out, parentInputs) err := mergo.Merge(&out, childInputs, mergo.WithAppendSlice, mergo.WithOverride) return out, errors.New(err) } // Merge the hooks (before_hook and after_hook). // // If a child's hook (before_hook or after_hook) has the same name a parent's hook, // then the child's hook will be selected (and the parent's ignored) // If a child's hook has a different name from all of the parent's hooks, // then the child's hook will be added to the end of the parent's. // Therefore, the child with the same name overrides the parent func mergeHooks(l log.Logger, childHooks []Hook, parentHooks *[]Hook) { result := *parentHooks for _, child := range childHooks { parentHookWithSameName := getIndexOfHookWithName(result, child.Name) if parentHookWithSameName != -1 { // If the parent contains a hook with the same name as the child, // then override the parent's hook with the child's. l.Debugf("hook '%v' from child overriding parent", child.Name) result[parentHookWithSameName] = child } else { // If the parent does not contain a hook with the same name as the child // then add the child to the end. result = append(result, child) } } *parentHooks = result } // Merge the error hooks (error_hook). // Does the same thing as mergeHooks but for error hooks // TODO: Figure out more DRY way to do this func mergeErrorHooks(l log.Logger, childHooks []ErrorHook, parentHooks *[]ErrorHook) { result := *parentHooks for _, child := range childHooks { parentHookWithSameName := getIndexOfErrorHookWithName(result, child.Name) if parentHookWithSameName != -1 { // If the parent contains a hook with the same name as the child, // then override the parent's hook with the child's. l.Debugf("hook '%v' from child overriding parent", child.Name) result[parentHookWithSameName] = child } else { // If the parent does not contain a hook with the same name as the child // then add the child to the end. result = append(result, child) } } *parentHooks = result } // getTrackInclude converts the terragrunt include blocks into TrackInclude structs that differentiate between an // included config in the current parsing ctx, and an included config that was passed through from a previous // parsing ctx. func getTrackInclude(ctx *ParsingContext, terragruntIncludeList IncludeConfigs, includeFromChild *IncludeConfig) (*TrackInclude, error) { includedPaths := make([]string, 0, len(terragruntIncludeList)) terragruntIncludeMap := make(map[string]IncludeConfig, len(terragruntIncludeList)) for _, tgInc := range terragruntIncludeList { includedPaths = append(includedPaths, tgInc.Path) terragruntIncludeMap[tgInc.Name] = tgInc } hasInclude := len(terragruntIncludeList) > 0 trackInc := TrackInclude{ CurrentList: terragruntIncludeList, CurrentMap: terragruntIncludeMap, } switch { case hasInclude && includeFromChild != nil: // tgInc appears in a parent that is already included, which means a nested include block. This is not // something we currently support. err := errors.New(TooManyLevelsOfInheritanceError{ ConfigPath: ctx.TerragruntConfigPath, FirstLevelIncludePath: includeFromChild.Path, SecondLevelIncludePath: strings.Join(includedPaths, ","), }) return &TrackInclude{}, err case hasInclude && includeFromChild == nil: // Current parsing ctx where there is no included config already loaded. case !hasInclude: // Parsing ctx where there is an included config already loaded. trackInc.Original = includeFromChild } return &trackInc, nil } // updateBareIncludeBlock searches the parsed terragrunt contents for a bare include block (include without a label), // and convert it to one with empty string as the label. This is necessary because the hcl parser is strictly enforces // label counts when parsing out labels with a go struct. // // Returns the updated contents, a boolean indicated whether anything changed, and an error (if any). func updateBareIncludeBlock(file *hclparse.File) error { // To save us from doing a lot of extra work, first going to check to see if the file has a naked include, first. // If it doesn't, we aren't going to bother fully parsing the file. if !detectBareIncludeUsage(file) { return nil } var ( codeWasUpdated bool content []byte err error ) switch filepath.Ext(file.ConfigPath) { case ".json": content, codeWasUpdated, err = updateBareIncludeBlockJSON(file.Bytes) if err != nil { return err } default: hclFile, diags := hclwrite.ParseConfig(file.Bytes, file.ConfigPath, hcl.InitialPos) if diags.HasErrors() { return errors.New(diags) } for _, block := range hclFile.Body().Blocks() { if block.Type() == MetadataInclude && len(block.Labels()) == 0 { if codeWasUpdated { return errors.New(MultipleBareIncludeBlocksErr{}) } block.SetLabels([]string{bareIncludeKey}) codeWasUpdated = true } } content = hclFile.Bytes() } if !codeWasUpdated { return nil } return file.Update(content) } // updateBareIncludeBlockJSON implements the logic for updateBareIncludeBlock when the terragrunt.hcl configuration is // encoded in json. The json version of this function is fairly complex due to the flexibility in how the blocks are // encoded. That is, all of the following are valid encodings of a terragrunt.hcl.json file that has a bare include // block: // // Case 1: a single include block as top level: // // { // "include": { // "path": "foo" // } // } // // Case 2: a single include block in list: // // { // "include": [ // {"path": "foo"} // ] // } // // Case 3: mixed bare and labeled include block as list: // // { // "include": [ // {"path": "foo"}, // { // "labeled": {"path": "bar"} // } // ] // } // // For simplicity of implementation, we focus on handling Case 1 and 2, and ignore Case 3. If we see Case 3, we will // error out. Instead, the user should handle this case explicitly using the object encoding instead of list encoding: // // { // "include": { // "": {"path": "foo"}, // "labeled": {"path": "bar"} // } // } // // If the multiple include blocks are encoded in this way in the json configuration, nothing needs to be done by this // function. func updateBareIncludeBlockJSON(fileBytes []byte) ([]byte, bool, error) { var parsed map[string]any if err := json.Unmarshal(fileBytes, &parsed); err != nil { return nil, false, errors.New(err) } includeBlock, hasKey := parsed[MetadataInclude] if !hasKey { // No include block, so don't do anything return fileBytes, false, nil } switch typed := includeBlock.(type) { case []any: if len(typed) == 0 { // No include block, so don't do anything return nil, false, nil } else if len(typed) > 1 { // Could be multiple bare includes, or Case 3. We simplify the handling of this case by erroring out, // ignoring the possibility of Case 3, which, while valid HCL encoding, is too complex to detect and handle // here. Instead we will recommend users use the object encoding. return nil, false, errors.New(MultipleBareIncludeBlocksErr{}) } // Make sure this is Case 2, and not Case 3 with a single labeled block. If Case 2, update to inject the labeled // version. Otherwise, return without modifying. singleBlock := typed[0] if jsonIsIncludeBlock(singleBlock) { return updateSingleBareIncludeInParsedJSON(parsed, singleBlock) } return nil, false, nil case map[string]any: if len(typed) == 0 { // No include block, so don't do anything return nil, false, nil } // We will only update the include block if we detect the object to represent an include block. Otherwise, the // blocks are labeled so we can pass forward to the tg parser step. if jsonIsIncludeBlock(typed) { return updateSingleBareIncludeInParsedJSON(parsed, typed) } return nil, false, nil } return nil, false, errors.New(IncludeIsNotABlockErr{parsed: includeBlock}) } // updateSingleBareIncludeInParsedJSON replaces the include attribute into a block with the label "" in the json. Note that we // can directly assign to the map with the single "" key without worrying about the possibility of other include blocks // since we will only call this function if there is only one include block, and that is a bare block with no labels. func updateSingleBareIncludeInParsedJSON(parsed map[string]any, newVal any) ([]byte, bool, error) { parsed[MetadataInclude] = map[string]any{bareIncludeKey: newVal} updatedBytes, err := json.Marshal(parsed) return updatedBytes, true, errors.New(err) } // jsonIsIncludeBlock checks if the arbitrary json data is the include block. The data is determined to be an include // block if: // - It is an object // - Has the 'path' attribute // - The 'path' attribute is a string func jsonIsIncludeBlock(jsonData any) bool { typed, isMap := jsonData.(map[string]any) if isMap { pathAttr, hasPath := typed["path"] if hasPath { _, pathIsString := pathAttr.(string) return pathIsString } } return false } // CopyFieldsMetadata Copy fields metadata between TerragruntConfig instances. func CopyFieldsMetadata(sourceConfig *TerragruntConfig, targetConfig *TerragruntConfig) { fieldsCopyLocks.Lock(targetConfig.DownloadDir) defer fieldsCopyLocks.Unlock(targetConfig.DownloadDir) if sourceConfig.FieldsMetadata != nil { if targetConfig.FieldsMetadata == nil { targetConfig.FieldsMetadata = map[string]map[string]any{} } maps.Copy(targetConfig.FieldsMetadata, sourceConfig.FieldsMetadata) } } // validateGenerateConfigs Validate if exists duplicate generate configs. func validateGenerateConfigs(sourceConfig *map[string]codegen.GenerateConfig, targetConfig *map[string]codegen.GenerateConfig) error { var duplicatedNames []string for key := range *targetConfig { if _, found := (*sourceConfig)[key]; found { duplicatedNames = append(duplicatedNames, key) } } if len(duplicatedNames) != 0 { return DuplicatedGenerateBlocksError{duplicatedNames} } return nil } // Custom error types type MultipleBareIncludeBlocksErr struct{} func (err MultipleBareIncludeBlocksErr) Error() string { return "Multiple bare include blocks (include blocks without label) is not supported." } type IncludeIsNotABlockErr struct { parsed any } func (err IncludeIsNotABlockErr) Error() string { return fmt.Sprintf("Parsed include is not a block: %v", err.parsed) } ================================================ FILE: pkg/config/include_test.go ================================================ package config_test import ( "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) func TestMergeConfigIntoIncludedConfig(t *testing.T) { t.Parallel() testCases := []struct { config *config.TerragruntConfig includedConfig *config.TerragruntConfig expected *config.TerragruntConfig }{ { &config.TerragruntConfig{}, &config.TerragruntConfig{}, &config.TerragruntConfig{}, }, { &config.TerragruntConfig{}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, }, { &config.TerragruntConfig{}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "bar"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "bar"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, }, { &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "foo"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "bar"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "foo"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "bar"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, &config.TerragruntConfig{RemoteState: remotestate.New(&remotestate.Config{BackendName: "bar"}), Terraform: &config.TerraformConfig{Source: ptr("foo")}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "childArgs"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "childArgs"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "childArgs"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "parentArgs"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "parentArgs"}, {Name: "childArgs"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "overrideArgs", Arguments: &[]string{"-child"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "overrideArgs", Arguments: &[]string{"-parent"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExtraArgs: []config.TerraformExtraArguments{{Name: "overrideArgs", Arguments: &[]string{"-child"}}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: nil}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: nil}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "parentHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "parentHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "parentHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "parentHooks"}, {Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"child-apply"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"parent-apply"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{BeforeHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"child-apply"}}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "parentHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "parentHooks"}, {Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"child-apply"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"parent-apply"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooks", Commands: []string{"child-apply"}}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooksPlusMore", Commands: []string{"child-apply"}}, {Name: "childHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooksPlusMore", Commands: []string{"parent-apply"}}, {Name: "parentHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideHooksPlusMore", Commands: []string{"child-apply"}}, {Name: "parentHooks"}, {Name: "childHooks"}}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideWithEmptyHooks"}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideWithEmptyHooks", Commands: []string{"parent-apply"}}}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{AfterHooks: []config.Hook{{Name: "overrideWithEmptyHooks"}}}}, }, { &config.TerragruntConfig{IamRole: "role2"}, &config.TerragruntConfig{IamRole: "role1"}, &config.TerragruntConfig{IamRole: "role2"}, }, { &config.TerragruntConfig{IamWebIdentityToken: "token"}, &config.TerragruntConfig{IamWebIdentityToken: "token"}, &config.TerragruntConfig{IamWebIdentityToken: "token"}, }, { &config.TerragruntConfig{IamWebIdentityToken: "token"}, &config.TerragruntConfig{IamWebIdentityToken: "token2"}, &config.TerragruntConfig{IamWebIdentityToken: "token"}, }, { &config.TerragruntConfig{}, &config.TerragruntConfig{IamWebIdentityToken: "token"}, &config.TerragruntConfig{IamWebIdentityToken: "token"}, }, { &config.TerragruntConfig{IamAssumeRoleSessionName: "session"}, &config.TerragruntConfig{IamAssumeRoleSessionName: "session2"}, &config.TerragruntConfig{IamAssumeRoleSessionName: "session"}, }, { &config.TerragruntConfig{}, &config.TerragruntConfig{IamAssumeRoleSessionName: "session"}, &config.TerragruntConfig{IamAssumeRoleSessionName: "session"}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0]}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"abc"}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], IncludeInCopy: &[]string{"abc"}}}, }, { &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0]}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"abc"}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], ExcludeFromCopy: &[]string{"abc"}}}, }, } for _, tc := range testCases { // if nil, initialize to empty dependency list if tc.expected.TerragruntDependencies == nil { tc.expected.TerragruntDependencies = config.Dependencies{} } err := tc.includedConfig.Merge(logger.CreateLogger(), tc.config) require.NoError(t, err) assert.EqualExportedValues(t, tc.expected, tc.includedConfig) } } func TestDeepMergeConfigIntoIncludedConfig(t *testing.T) { t.Parallel() // The following maps are convenience vars for setting up deep merge map tests overrideMap := map[string]any{ "simple_string_override": "hello, mock", "simple_string_append": "new val", "list_attr": []string{"mock"}, "map_attr": map[string]any{ "simple_string_override": "hello, mock", "simple_string_append": "new val", "list_attr": []string{"mock"}, "map_attr": map[string]any{ "simple_string_override": "hello, mock", "simple_string_append": "new val", "list_attr": []string{"mock"}, }, }, } originalMap := map[string]any{ "simple_string_override": "hello, world", "original_string": "original val", "list_attr": []string{"hello"}, "map_attr": map[string]any{ "simple_string_override": "hello, world", "original_string": "original val", "list_attr": []string{"hello"}, "map_attr": map[string]any{ "simple_string_override": "hello, world", "original_string": "original val", "list_attr": []string{"hello"}, }, }, } mergedMap := map[string]any{ "simple_string_override": "hello, mock", "original_string": "original val", "simple_string_append": "new val", "list_attr": []string{"hello", "mock"}, "map_attr": map[string]any{ "simple_string_override": "hello, mock", "original_string": "original val", "simple_string_append": "new val", "list_attr": []string{"hello", "mock"}, "map_attr": map[string]any{ "simple_string_override": "hello, mock", "original_string": "original val", "simple_string_append": "new val", "list_attr": []string{"hello", "mock"}, }, }, } testCases := []struct { source *config.TerragruntConfig target *config.TerragruntConfig expected *config.TerragruntConfig name string }{ // Base case: empty config { name: "base case", source: &config.TerragruntConfig{}, target: &config.TerragruntConfig{}, expected: &config.TerragruntConfig{}, }, // Simple attribute in target { name: "simple in target", source: &config.TerragruntConfig{}, target: &config.TerragruntConfig{IamRole: "foo"}, expected: &config.TerragruntConfig{IamRole: "foo"}, }, // Simple attribute in source { name: "simple in source", source: &config.TerragruntConfig{IamRole: "foo"}, target: &config.TerragruntConfig{}, expected: &config.TerragruntConfig{IamRole: "foo"}, }, // Simple attribute in both { name: "simple in both", source: &config.TerragruntConfig{IamRole: "foo"}, target: &config.TerragruntConfig{IamRole: "bar"}, expected: &config.TerragruntConfig{IamRole: "foo"}, }, // skip related tests // Deep merge dependencies { name: "dependencies", source: &config.TerragruntConfig{Dependencies: &config.ModuleDependencies{Paths: []string{"../vpc"}}, TerragruntDependencies: config.Dependencies{ config.Dependency{ Name: "vpc", ConfigPath: cty.StringVal("../vpc"), }, }}, target: &config.TerragruntConfig{Dependencies: &config.ModuleDependencies{Paths: []string{"../mysql"}}, TerragruntDependencies: config.Dependencies{ config.Dependency{ Name: "mysql", ConfigPath: cty.StringVal("../mysql"), }, }}, expected: &config.TerragruntConfig{Dependencies: &config.ModuleDependencies{Paths: []string{"../mysql", "../vpc"}}, TerragruntDependencies: config.Dependencies{ config.Dependency{ Name: "mysql", ConfigPath: cty.StringVal("../mysql"), }, config.Dependency{ Name: "vpc", ConfigPath: cty.StringVal("../vpc"), }, }}, }, // Deep merge retryable errors // Deep merge inputs { name: "inputs", source: &config.TerragruntConfig{Inputs: overrideMap}, target: &config.TerragruntConfig{Inputs: originalMap}, expected: &config.TerragruntConfig{Inputs: mergedMap}, }, { name: "terraform copy_terraform_lock_file", source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0]}}, target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"abc"}}}, expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], IncludeInCopy: &[]string{"abc"}}}, }, { name: "terraform copy_terraform_lock_file", source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0]}}, target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"abc"}}}, expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], ExcludeFromCopy: &[]string{"abc"}}}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() err := tc.target.DeepMerge(logger.CreateLogger(), tc.source) require.NoError(t, err) // if nil, initialize to empty dependency list if tc.expected.TerragruntDependencies == nil { tc.expected.TerragruntDependencies = config.Dependencies{} } assert.Equal(t, tc.expected, tc.target) }) } } func TestConcurrentCopyFieldsMetadata(t *testing.T) { t.Parallel() sourceConfig := &config.TerragruntConfig{ FieldsMetadata: map[string]map[string]any{ "field1": {"key1": "value1", "key2": "value2"}, "field2": {"key3": "value3", "key4": "value4"}, }, } targetConfig := &config.TerragruntConfig{} var wg sync.WaitGroup numGoroutines := 666 wg.Add(numGoroutines) for range numGoroutines { go func() { defer wg.Done() config.CopyFieldsMetadata(sourceConfig, targetConfig) }() } wg.Wait() // Optionally, here you can add assertions to check the integrity of the targetConfig // For example, checking if all keys and values have been copied correctly expectedFields := len(sourceConfig.FieldsMetadata) if len(targetConfig.FieldsMetadata) != expectedFields { t.Errorf("Expected %d fields, got %d", expectedFields, len(targetConfig.FieldsMetadata)) } } func TestDependencyFileNotFoundError(t *testing.T) { t.Parallel() // Test that DependencyFileNotFoundError is properly defined and formatted err := config.DependencyFileNotFoundError{Path: "/test/path/terragrunt.hcl"} assert.Equal(t, "/test/path/terragrunt.hcl", err.Path) assert.Contains(t, err.Error(), "Dependency file not found: /test/path/terragrunt.hcl") // Test with a different path err2 := config.DependencyFileNotFoundError{Path: "/another/path/config.hcl"} assert.Equal(t, "/another/path/config.hcl", err2.Path) assert.Contains(t, err2.Error(), "Dependency file not found: /another/path/config.hcl") } func TestIncludeConfigNotFoundError(t *testing.T) { t.Parallel() // Test that IncludeConfigNotFoundError is properly defined and formatted err := config.IncludeConfigNotFoundError{IncludePath: "/test/path/terragrunt.hcl", SourcePath: "/source/config.hcl"} assert.Equal(t, "/test/path/terragrunt.hcl", err.IncludePath) assert.Equal(t, "/source/config.hcl", err.SourcePath) assert.Contains(t, err.Error(), "Include configuration not found: /test/path/terragrunt.hcl (referenced from: /source/config.hcl)") // Test with a different path err2 := config.IncludeConfigNotFoundError{IncludePath: "/another/path/config.hcl", SourcePath: "/different/source.hcl"} assert.Equal(t, "/another/path/config.hcl", err2.IncludePath) assert.Equal(t, "/different/source.hcl", err2.SourcePath) assert.Contains(t, err2.Error(), "Include configuration not found: /another/path/config.hcl (referenced from: /different/source.hcl)") } ================================================ FILE: pkg/config/locals.go ================================================ package config import ( "context" "fmt" "strings" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "maps" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" ) // MaxIter is the maximum number of depth we support in recursively evaluating locals. const MaxIter = 1000 // EvaluateLocalsBlock is a routine to evaluate the locals block in a way to allow references to other locals. This // will: // - Extract a reference to the locals block from the parsed file // - Continuously evaluate the block until all references are evaluated, deferring evaluation of anything that references // other locals until those references are evaluated. // // This returns a map of the local names to the evaluated expressions (represented as `cty.Value` objects). This will // error if there are remaining unevaluated locals after all references that can be evaluated has been evaluated. func EvaluateLocalsBlock(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File) (map[string]cty.Value, error) { localsBlock, err := file.Blocks(MetadataLocals, false) if err != nil { return nil, err } if len(localsBlock) == 0 { // No locals block referenced in the file l.Debugf("Did not find any locals block: skipping evaluation.") return nil, nil } l.Debugf("Found locals block: evaluating the expressions.") attrs, err := localsBlock[0].JustAttributes() if err != nil { l.Debugf("Encountered error while decoding locals block into name expression pairs.") return nil, err } // Continuously attempt to evaluate the locals until there are no more locals to evaluate, or we can't evaluate // further. evaluatedLocals := map[string]cty.Value{} evaluated := true for iterations := 0; len(attrs) > 0 && evaluated; iterations++ { if iterations > MaxIter { // Reached maximum supported iterations, which is most likely an infinite loop bug so cut the iteration // short an return an error. return nil, errors.New(MaxIterError{}) } var err error attrs, evaluatedLocals, evaluated, err = attemptEvaluateLocals( ctx, pctx, l, file, attrs, evaluatedLocals, ) if err != nil { l.Debugf("Encountered error while evaluating locals in file %s", util.RelPathForLog(pctx.RootWorkingDir, pctx.TerragruntConfigPath, pctx.Writers.LogShowAbsPaths)) return evaluatedLocals, err } } if len(attrs) > 0 { // This is an error because we couldn't evaluate all locals l.Debugf("Not all locals could be evaluated:") var errs *errors.MultiError for _, attr := range attrs { diags := canEvaluateLocals(attr.Expr, evaluatedLocals) if err := file.HandleDiagnostics(diags); err != nil { errs = errs.Append(err) } } if err := errs.ErrorOrNil(); err != nil { return nil, errors.New(CouldNotEvaluateAllLocalsError{Err: err}) } } return evaluatedLocals, nil } // attemptEvaluateLocals attempts to evaluate the locals block given the map of already evaluated locals, replacing // references to locals with the previously evaluated values. This will return: // - the list of remaining locals that were unevaluated in this attempt // - the updated map of evaluated locals after this attempt // - whether or not any locals were evaluated in this attempt // - any errors from the evaluation func attemptEvaluateLocals( ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, attrs hclparse.Attributes, evaluatedLocals map[string]cty.Value, ) (unevaluatedAttrs hclparse.Attributes, newEvaluatedLocals map[string]cty.Value, evaluated bool, err error) { localsAsCtyVal, err := ConvertValuesMapToCtyVal(evaluatedLocals) if err != nil { l.Errorf("Could not convert evaluated locals to the execution ctx to evaluate additional locals in file %s", file.ConfigPath) return nil, evaluatedLocals, false, err } pctx.Locals = &localsAsCtyVal evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { l.Errorf("Could not convert include to the execution ctx to evaluate additional locals in file %s", file.ConfigPath) return nil, evaluatedLocals, false, err } // Track the locals that were evaluated for logging purposes newlyEvaluatedLocalNames := []string{} unevaluatedAttrs = hclparse.Attributes{} evaluated = false newEvaluatedLocals = make(map[string]cty.Value, len(evaluatedLocals)) maps.Copy(newEvaluatedLocals, evaluatedLocals) errs := &errors.MultiError{} for _, attr := range attrs { if diags := canEvaluateLocals(attr.Expr, evaluatedLocals); !diags.HasErrors() { evaluatedVal, err := attr.Value(evalCtx) if err != nil { errs = errs.Append(err) continue } newEvaluatedLocals[attr.Name] = evaluatedVal newlyEvaluatedLocalNames = append(newlyEvaluatedLocalNames, attr.Name) evaluated = true } else { unevaluatedAttrs = append(unevaluatedAttrs, attr) } } l.Debugf( "Evaluated %d locals (remaining %d): %s", len(newlyEvaluatedLocalNames), len(unevaluatedAttrs), strings.Join(newlyEvaluatedLocalNames, ", "), ) return unevaluatedAttrs, newEvaluatedLocals, evaluated, errs.ErrorOrNil() } // canEvaluateLocals determines if the local expression can be evaluated. An expression can be evaluated if one of the // following is true: // - It has no references to other locals. // - It has references to other locals that have already been evaluated. // Note that the second return value is a human friendly reason for why the expression can not be evaluated, and is // useful for error reporting. func canEvaluateLocals(expression hcl.Expression, evaluatedLocals map[string]cty.Value) hcl.Diagnostics { var diags hcl.Diagnostics localVars := expression.Variables() for _, localVar := range localVars { var ( rootName = localVar.RootName() localName = getLocalName(localVar) _, hasEvaluated = evaluatedLocals[localName] detail string ) switch { case localVar.IsRelative(): // This should never happen, but if it does, we can't evaluate this expression. detail = "This caused an impossible condition, tnis is almost certainly a bug in Terragrunt. Please open an issue at github.com/gruntwork-io/terragrunt with this message and the contents of your terragrunt.hcl file that caused this." case rootName == MetadataInclude: // If the variable is `include`, then we can evaluate it now case rootName == MetadataFeatureFlag: // If the variable is `feature` case rootName == MetadataValues: // If the variable is `values` case rootName != "local": // We can't evaluate any variable other than `local` detail = fmt.Sprintf("You can only reference to other local variables here, but it looks like you're referencing something else (%q is not defined)", rootName) case localName == "": // If we can't get any local name, we can't evaluate it. detail = "This local var name can not be determined." case !hasEvaluated: // If the referenced local isn't evaluated, we can't evaluate this expression. detail = fmt.Sprintf("The local reference '%s' is not evaluated. Either it is not ready yet in the current pass, or there was an error evaluating it in an earlier stage.", localName) } if detail != "" { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Can't evaluate expression", Detail: detail, Subject: expression.Range().Ptr(), }) } } return diags } // getLocalName takes a variable reference encoded as a HCL tree traversal that is rooted at the name `local` and // returns the underlying variable lookup on the local map. If it is not a local name lookup, this will return empty // string. func getLocalName(traversal hcl.Traversal) string { if traversal.IsRelative() { return "" } if traversal.RootName() != "local" { return "" } split := traversal.SimpleSplit() for _, relRaw := range split.Rel { switch rel := relRaw.(type) { case hcl.TraverseAttr: return rel.Name default: // This means that it is either an operation directly on the locals block, or is an unsupported action (e.g // a splat or lookup). Either way, there is no local name. continue } } return "" } // ------------------------------------------------ // Custom Errors Returned by Functions in this Code // ------------------------------------------------ type CouldNotEvaluateAllLocalsError struct { Err error } func (err CouldNotEvaluateAllLocalsError) Error() string { return "Could not evaluate all locals in block." } func (err CouldNotEvaluateAllLocalsError) Unwrap() error { return err.Err } type MaxIterError struct{} func (err MaxIterError) Error() string { return "Maximum iterations reached in attempting to evaluate locals. This is most likely a bug in Terragrunt. Please file an issue on the project: https://github.com/gruntwork-io/terragrunt/issues" } ================================================ FILE: pkg/config/locals_test.go ================================================ package config_test import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty/gocty" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/test/helpers/logger" ) func TestEvaluateLocalsBlock(t *testing.T) { t.Parallel() file, err := hclparse.NewParser().ParseFromString(LocalsTestConfig, config.DefaultTerragruntConfigPath) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) evaluatedLocals, err := config.EvaluateLocalsBlock(ctx, pctx, logger.CreateLogger(), file) require.NoError(t, err) var actualRegion string require.NoError(t, gocty.FromCtyValue(evaluatedLocals["region"], &actualRegion)) assert.Equal(t, "us-east-1", actualRegion) var actualS3Url string require.NoError(t, gocty.FromCtyValue(evaluatedLocals["s3_url"], &actualS3Url)) assert.Equal(t, "com.amazonaws.us-east-1.s3", actualS3Url) var actualX float64 require.NoError(t, gocty.FromCtyValue(evaluatedLocals["x"], &actualX)) assert.InEpsilon(t, float64(1), actualX, 0.0000001) var actualY float64 //codespell:ignore require.NoError(t, gocty.FromCtyValue(evaluatedLocals["y"], &actualY)) //codespell:ignore assert.InEpsilon(t, float64(2), actualY, 0.0000001) //codespell:ignore var actualZ float64 require.NoError(t, gocty.FromCtyValue(evaluatedLocals["z"], &actualZ)) assert.InEpsilon(t, float64(3), actualZ, 0.0000001) var actualFoo struct{ First Foo } require.NoError(t, gocty.FromCtyValue(evaluatedLocals["foo"], &actualFoo)) assert.Equal(t, Foo{ Region: "us-east-1", Foo: "bar", }, actualFoo.First) var actualBar string require.NoError(t, gocty.FromCtyValue(evaluatedLocals["bar"], &actualBar)) assert.Equal(t, "us-east-1", actualBar) } func TestEvaluateLocalsBlockMultiDeepReference(t *testing.T) { t.Parallel() file, err := hclparse.NewParser().ParseFromString(LocalsTestMultiDeepReferenceConfig, config.DefaultTerragruntConfigPath) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) evaluatedLocals, err := config.EvaluateLocalsBlock(ctx, pctx, logger.CreateLogger(), file) require.NoError(t, err) expected := "a" var actualA string require.NoError(t, gocty.FromCtyValue(evaluatedLocals["a"], &actualA)) assert.Equal(t, expected, actualA) testCases := []string{ "b", "c", "d", "e", "f", "g", "h", "i", "j", } for _, tc := range testCases { expected = fmt.Sprintf("%s/%s", expected, tc) var actual string require.NoError(t, gocty.FromCtyValue(evaluatedLocals[tc], &actual)) assert.Equal(t, expected, actual) } } func TestEvaluateLocalsBlockImpossibleWillFail(t *testing.T) { t.Parallel() file, err := hclparse.NewParser().ParseFromString(LocalsTestImpossibleConfig, config.DefaultTerragruntConfigPath) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) _, err = config.EvaluateLocalsBlock(ctx, pctx, logger.CreateLogger(), file) require.Error(t, err) switch errors.Unwrap(err).(type) { //nolint:errorlint case config.CouldNotEvaluateAllLocalsError: default: t.Fatalf("Did not get expected error: %s", err) } } func TestEvaluateLocalsBlockMultipleLocalsBlocksWillFail(t *testing.T) { t.Parallel() file, err := hclparse.NewParser().ParseFromString(MultipleLocalsBlockConfig, config.DefaultTerragruntConfigPath) require.NoError(t, err) ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) _, err = config.EvaluateLocalsBlock(ctx, pctx, logger.CreateLogger(), file) require.Error(t, err) } type Foo struct { Region string `cty:"region"` Foo string `cty:"foo"` } const LocalsTestConfig = ` locals { region = "us-east-1" // Simple reference s3_url = "com.amazonaws.${local.region}.s3" // Nested reference foo = [ merge( {region = local.region}, {foo = "bar"}, ) ] bar = local.foo[0]["region"] // Multiple references x = 1 y = 2 z = local.x + local.y } ` const LocalsTestMultiDeepReferenceConfig = ` # 10 chains deep locals { a = "a" b = "${local.a}/b" c = "${local.b}/c" d = "${local.c}/d" e = "${local.d}/e" f = "${local.e}/f" g = "${local.f}/g" h = "${local.g}/h" i = "${local.h}/i" j = "${local.i}/j" } ` const LocalsTestImpossibleConfig = ` locals { a = local.b b = local.a } ` const MultipleLocalsBlockConfig = ` locals { a = "a" } locals { b = "b" } ` ================================================ FILE: pkg/config/options.go ================================================ package config import "github.com/gruntwork-io/terragrunt/internal/strict" // Option is a functional option for NewParsingContext. type Option func(*ParsingContext) // WithStrictControls sets the strict controls for the parsing context. func WithStrictControls(controls strict.Controls) Option { return func(pctx *ParsingContext) { pctx.StrictControls = controls } } ================================================ FILE: pkg/config/parsing_context.go ================================================ package config import ( "context" "io" "maps" "os" "path/filepath" "slices" "github.com/puzpuzpuz/xsync/v3" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/iam" pcoptions "github.com/gruntwork-io/terragrunt/internal/providercache/options" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" ) const ( // MaxParseDepth limits nested parsing to prevent stack overflow // from deeply recursive config structures (includes, dependencies, etc.). MaxParseDepth = 1000 ) // ParsingContext provides various variables that are used throughout all funcs and passed from function to function. // Using `ParsingContext` makes the code more readable. // Note: context.Context should be passed explicitly as the first parameter to functions, not embedded in this struct. type ParsingContext struct { Writers writer.Writers TerraformCliArgs *iacargs.IacArgs TrackInclude *TrackInclude EngineConfig *engine.EngineConfig EngineOptions *engine.EngineOptions FeatureFlags *xsync.MapOf[string, string] FilesRead *[]string Telemetry *telemetry.Options DecodedDependencies *cty.Value Values *cty.Value Features *cty.Value Locals *cty.Value Env map[string]string SourceMap map[string]string PredefinedFunctions map[string]function.Function ConvertToTerragruntConfigFunc func(ctx context.Context, pctx *ParsingContext, configPath string, terragruntConfigFromFile *terragruntConfigFile) (cfg *TerragruntConfig, err error) TerragruntConfigPath string OriginalTerragruntConfigPath string WorkingDir string RootWorkingDir string DownloadDir string Source string TerraformCommand string OriginalTerraformCommand string AuthProviderCmd string TFPath string ScaffoldRootFileName string TerragruntStackConfigPath string TofuImplementation tfimpl.Type IAMRoleOptions iam.RoleOptions OriginalIAMRoleOptions iam.RoleOptions Experiments experiment.Experiments StrictControls strict.Controls PartialParseDecodeList []PartialDecodeSectionType ParserOptions []hclparse.Option ProviderCacheOptions pcoptions.ProviderCacheOptions MaxFoldersToCheck int ParseDepth int TFPathExplicitlySet bool SkipOutput bool ForwardTFStdout bool JSONLogFormat bool Debug bool AutoInit bool Headless bool BackendBootstrap bool CheckDependentUnits bool NoDependencyFetchOutputFromState bool UsePartialParseConfigCache bool SkipOutputsResolution bool NoStackValidate bool } func NewParsingContext(ctx context.Context, l log.Logger, opts ...Option) (context.Context, *ParsingContext) { filesRead := make([]string, 0) pctx := &ParsingContext{ TerraformCliArgs: iacargs.New(), FilesRead: &filesRead, } for _, opt := range opts { opt(pctx) } pctx.ParserOptions = DefaultParserOptions(l, pctx.StrictControls) return ctx, pctx } // Clone returns a copy of the ParsingContext. // Maps are deep-copied so that mutations (e.g. credential injection into Env) // on a clone do not affect the original or other clones. func (ctx *ParsingContext) Clone() *ParsingContext { clone := *ctx if ctx.Env != nil { clone.Env = maps.Clone(ctx.Env) } if ctx.SourceMap != nil { clone.SourceMap = maps.Clone(ctx.SourceMap) } if ctx.EngineOptions != nil { eo := *ctx.EngineOptions clone.EngineOptions = &eo } clone.ProviderCacheOptions.RegistryNames = slices.Clone(ctx.ProviderCacheOptions.RegistryNames) return &clone } func (ctx *ParsingContext) WithDecodeList(decodeList ...PartialDecodeSectionType) *ParsingContext { c := ctx.Clone() c.PartialParseDecodeList = decodeList return c } func (ctx *ParsingContext) WithLocals(locals *cty.Value) *ParsingContext { c := ctx.Clone() c.Locals = locals return c } func (ctx *ParsingContext) WithValues(values *cty.Value) *ParsingContext { c := ctx.Clone() c.Values = values return c } // WithFeatures sets the feature flags to be used in evaluation context. func (ctx *ParsingContext) WithFeatures(features *cty.Value) *ParsingContext { c := ctx.Clone() c.Features = features return c } func (ctx *ParsingContext) WithTrackInclude(trackInclude *TrackInclude) *ParsingContext { c := ctx.Clone() c.TrackInclude = trackInclude return c } func (ctx *ParsingContext) WithParseOption(parserOptions []hclparse.Option) *ParsingContext { c := ctx.Clone() c.ParserOptions = parserOptions return c } // WithDiagnosticsSuppressed returns a new ParsingContext with diagnostics suppressed. // Diagnostics are written to stderr in debug mode for troubleshooting, otherwise discarded. // This avoids false positive "There is no variable named dependency" errors during parsing // when dependency outputs haven't been resolved yet. func (ctx *ParsingContext) WithDiagnosticsSuppressed(l log.Logger) *ParsingContext { var diagWriter = io.Discard if l.Level() >= log.DebugLevel { diagWriter = os.Stderr } c := ctx.Clone() c.ParserOptions = slices.Concat(ctx.ParserOptions, []hclparse.Option{hclparse.WithDiagnosticsWriter(diagWriter, true)}) return c } func (ctx *ParsingContext) WithSkipOutputsResolution() *ParsingContext { c := ctx.Clone() c.SkipOutputsResolution = true return c } // WithIncrementedDepth returns a new ParsingContext with incremented parse depth. // Returns an error if the maximum depth would be exceeded. func (ctx *ParsingContext) WithIncrementedDepth() (*ParsingContext, error) { if ctx.ParseDepth > MaxParseDepth { return nil, errors.New(MaxParseDepthError{ Depth: ctx.ParseDepth, Max: MaxParseDepth, }) } c := ctx.Clone() c.ParseDepth = ctx.ParseDepth + 1 return c, nil } // WithConfigPath returns a new ParsingContext with the config path updated. // It normalizes the path to an absolute path, updates WorkingDir to the directory // containing the config, and adjusts the logger's working directory field if it changed. func (ctx *ParsingContext) WithConfigPath(l log.Logger, configPath string) (log.Logger, *ParsingContext, error) { configPath = filepath.Clean(configPath) if !filepath.IsAbs(configPath) { configPath = filepath.Clean(filepath.Join(ctx.WorkingDir, configPath)) } workingDir := filepath.Dir(configPath) if workingDir != ctx.WorkingDir { l = l.WithField(placeholders.WorkDirKeyName, workingDir) } c := ctx.Clone() c.TerragruntConfigPath = configPath c.WorkingDir = workingDir return l, c, nil } ================================================ FILE: pkg/config/sops_race_test.go ================================================ package config //nolint:testpackage // needs access to sopsDecryptFileImpl import ( "fmt" "os" "path/filepath" "sync" "testing" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestSOPSDecryptConcurrencyWithRacing is a regression test for // https://github.com/gruntwork-io/terragrunt/issues/5515 // // Run with -race to detect data races in env var handling during concurrent // SOPS decryption. The CI "Race" job runs tests matching .*WithRacing with -race. // // Multiple goroutines call sopsDecryptFileImpl concurrently with different // opts.Env credentials. Without proper locking, the race detector catches // concurrent os.Setenv/os.Getenv/os.Unsetenv calls. func TestSOPSDecryptConcurrencyWithRacing(t *testing.T) { t.Parallel() const ( authKey = "SOPS_RACE_TEST_TOKEN" numGoroutines = 10 ) dir := t.TempDir() var files []string for i := 1; i <= numGoroutines; i++ { unitDir := filepath.Join(dir, fmt.Sprintf("unit-%02d", i)) require.NoError(t, os.MkdirAll(unitDir, 0755)) secretFile := filepath.Join(unitDir, "secret.enc.json") require.NoError(t, os.WriteFile(secretFile, []byte(fmt.Sprintf(`{"value":"secret-from-unit-%02d"}`, i)), 0644)) files = append(files, secretFile) } // Mock decrypt that reads the env var (creating a read that races with // concurrent Setenv/Unsetenv if locking is broken). mockDecryptFn := func(path string, _ string) ([]byte, error) { _ = os.Getenv(authKey) // read that would race without lock return os.ReadFile(path) } var ( wg sync.WaitGroup barrier = make(chan struct{}) ) ctx := WithConfigValues(t.Context()) for i, f := range files { wg.Add(1) go func(idx int, filePath string) { defer wg.Done() <-barrier l := logger.CreateLogger() _, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New())) pctx.WorkingDir = filepath.Dir(filePath) pctx.Env = map[string]string{authKey: fmt.Sprintf("token-%d", idx)} result, err := sopsDecryptFileImpl(ctx, pctx, l, filePath, "json", mockDecryptFn) assert.NoError(t, err) assert.Contains(t, result, `"value":"secret-from-unit-`) }(i, f) } close(barrier) wg.Wait() // Verify env is clean after all goroutines complete. _, exists := os.LookupEnv(authKey) require.False(t, exists, "env var must be cleaned up after concurrent decrypts") } ================================================ FILE: pkg/config/sops_test.go ================================================ //go:build sops package config //nolint:testpackage // needs access to sopsDecryptFileImpl import ( "errors" "fmt" "os" "path/filepath" "sync" "sync/atomic" "testing" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // generateTestSecretFiles creates plain JSON files in a temp directory. // No SOPS encryption needed — the test injects a mock decryptFn to read raw files. func generateTestSecretFiles(t *testing.T, count int) []string { t.Helper() dir := t.TempDir() var files []string for i := 1; i <= count; i++ { unitDir := filepath.Join(dir, fmt.Sprintf("unit-%02d", i)) require.NoError(t, os.MkdirAll(unitDir, 0755)) secretFile := filepath.Join(unitDir, "secret.enc.json") require.NoError(t, os.WriteFile(secretFile, []byte(fmt.Sprintf(`{"value":"secret-from-unit-%02d"}`, i)), 0644)) files = append(files, secretFile) } return files } // TestSOPSDecryptEnvPropagation is a deterministic regression test for // https://github.com/gruntwork-io/terragrunt/issues/5515 // // The original customer-reported bug: sops_decrypt_file() during HCL evaluation // couldn't authenticate to KMS because auth-provider credentials were not yet // loaded into opts.Env. This caused SOPS to return empty/wrong secrets. // // This test verifies the env propagation contract of sopsDecryptFileImpl: // - Existing process env vars are preserved (not overridden by opts.Env) // - Missing env vars from opts.Env are set during decrypt and unset after // - Without credentials, decrypt fails (reproduces the original bug) // - Concurrent goroutines with different credentials are properly isolated func TestSOPSDecryptEnvPropagation(t *testing.T) { //nolint:paralleltest // mutates process env vars const authKey = "SOPS_TEST_AUTH_CRED" t.Cleanup(func() { os.Unsetenv(authKey) //nolint:errcheck }) secretFiles := generateTestSecretFiles(t, 1) secretFile := secretFiles[0] // Mock decryptFn that requires authKey to be set — simulates KMS auth. authRequiringDecryptFn := func(path string, _ string) ([]byte, error) { token := os.Getenv(authKey) if token == "" { return nil, errors.New("KMS auth failed: no credential set") } return os.ReadFile(path) } // Subtest 1: Existing process env vars are preserved (not overridden). // Models: CI runner has real AWS_SESSION_TOKEN, auth-provider returns empty token. // sopsDecryptFileImpl must NOT override the real token with empty — SOPS uses process env. t.Run("existing_process_env_preserved", func(t *testing.T) { //nolint:paralleltest // mutates process env t.Setenv(authKey, "real-ci-token") l := logger.CreateLogger() ctx := WithConfigValues(t.Context()) _, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New())) pctx.WorkingDir = filepath.Dir(secretFile) // pctx.Env has empty value for authKey (like auth-provider returning empty session token) pctx.Env = map[string]string{authKey: ""} result, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) require.NoError(t, err, "decrypt must succeed using existing process env credentials") assert.Contains(t, result, `"value":"secret-from-unit-01"`) // Process env must still have the real token — not overridden assert.Equal(t, "real-ci-token", os.Getenv(authKey), "existing process env var must not be overridden") }) // Subtest 2: Credentials injected when absent from process env. // Models: first run, auth-provider loaded creds into opts.Env, process env was empty. t.Run("new_creds_set_when_absent_from_process_env", func(t *testing.T) { //nolint:paralleltest // mutates process env os.Unsetenv(authKey) //nolint:errcheck l := logger.CreateLogger() ctx := WithConfigValues(t.Context()) _, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New())) pctx.WorkingDir = filepath.Dir(secretFile) pctx.Env = map[string]string{authKey: "fresh-token"} result, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) require.NoError(t, err, "decrypt must succeed with fresh credentials from opts.Env") assert.Contains(t, result, `"value":"secret-from-unit-01"`) // Process env must be unset (not empty string) after decrypt _, exists := os.LookupEnv(authKey) assert.False(t, exists, "env var must be unset after decrypt, not set to empty string") }) // Subtest 3: Missing credentials cause decrypt failure. // Reproduces the ORIGINAL bug: auth-provider hasn't run yet, opts.Env has no // auth token, process env has no auth token → SOPS can't authenticate to KMS. t.Run("missing_creds_fails_decrypt", func(t *testing.T) { //nolint:paralleltest // mutates process env os.Unsetenv(authKey) //nolint:errcheck l := logger.CreateLogger() ctx := WithConfigValues(t.Context()) _, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New())) pctx.WorkingDir = filepath.Dir(secretFile) // Empty env — simulates auth-provider NOT having run (the original bug) pctx.Env = map[string]string{} _, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) require.Error(t, err, "decrypt must fail without auth credentials — reproduces original issue #5515") }) // Subtest 4: Concurrent goroutines with DIFFERENT auth tokens are isolated. // Models production: multiple units decrypt in parallel, each with different // auth-provider credentials. The lock must ensure each sees its OWN token. t.Run("concurrent_different_creds_isolated", func(t *testing.T) { //nolint:paralleltest // mutates process env const numGoroutines = 5 os.Unsetenv(authKey) //nolint:errcheck files := generateTestSecretFiles(t, numGoroutines) var wg sync.WaitGroup barrier := make(chan struct{}) var failures atomic.Int32 ctx := WithConfigValues(t.Context()) for i, f := range files { wg.Add(1) go func(idx int, filePath string) { defer wg.Done() <-barrier expectedToken := fmt.Sprintf("token-%d", idx) // Each goroutine's decryptFn verifies it sees ITS OWN token tokenCheckFn := func(path string, _ string) ([]byte, error) { actual := os.Getenv(authKey) if actual != expectedToken { return nil, fmt.Errorf("goroutine %d: expected %q, got %q", idx, expectedToken, actual) } return os.ReadFile(path) } l := logger.CreateLogger() _, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New())) pctx.WorkingDir = filepath.Dir(filePath) pctx.Env = map[string]string{authKey: expectedToken} result, decryptErr := sopsDecryptFileImpl(ctx, pctx, l, filePath, "json", tokenCheckFn) if decryptErr != nil { t.Logf("goroutine %d failed: %v", idx, decryptErr) failures.Add(1) return } expectedPrefix := `{"value":"secret-from-unit-` if len(result) < len(expectedPrefix) || result[:len(expectedPrefix)] != expectedPrefix { t.Logf("goroutine %d: wrong content: %s", idx, result) failures.Add(1) } }(i, f) } close(barrier) wg.Wait() require.Zero(t, failures.Load(), "all goroutines must see their own auth token during decrypt — env isolation failed") assert.Empty(t, os.Getenv(authKey), "process env must be clean after all concurrent decrypts") }) } ================================================ FILE: pkg/config/stack.go ================================================ package config import ( "context" "fmt" "os" "path/filepath" "sort" "strings" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/worker" "github.com/hashicorp/go-getter/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) const ( StackDir = ".terragrunt-stack" valuesFile = "terragrunt.values.hcl" manifestName = ".terragrunt-stack-manifest" unitDirPerm = 0755 valueFilePerm = 0644 ) // StackConfigFile represents the structure of terragrunt.stack.hcl stack file. type StackConfigFile struct { Locals *terragruntLocal `hcl:"locals,block"` Stacks []*Stack `hcl:"stack,block"` Units []*Unit `hcl:"unit,block"` } // StackConfig represents the structure of terragrunt.stack.hcl stack file. type StackConfig struct { Locals map[string]any Stacks []*Stack Units []*Unit } // Unit represents unit from a stack file. type Unit struct { NoStack *bool `hcl:"no_dot_terragrunt_stack,attr"` NoValidation *bool `hcl:"no_validation,attr"` Values *cty.Value `hcl:"values,attr"` Name string `hcl:",label"` Source string `hcl:"source,attr"` Path string `hcl:"path,attr"` } // Stack represents the stack block in the configuration. type Stack struct { NoStack *bool `hcl:"no_dot_terragrunt_stack,attr"` NoValidation *bool `hcl:"no_validation,attr"` Values *cty.Value `hcl:"values,attr"` Name string `hcl:",label"` Source string `hcl:"source,attr"` Path string `hcl:"path,attr"` } // GenerateStackFile generates the Terragrunt stack configuration from the given stackFilePath, // reads necessary values, and generates units and stacks in the target directory. // It handles the creation of required directories and returns any errors encountered. func GenerateStackFile(ctx context.Context, l log.Logger, pctx *ParsingContext, pool *worker.Pool, stackFilePath string) error { stackSourceDir := filepath.Dir(stackFilePath) values, err := ReadValues(ctx, pctx, l, stackSourceDir) if err != nil { return errors.Errorf("failed to read values from directory %s: %w", stackSourceDir, err) } stackFile, err := ReadStackConfigFile(ctx, l, pctx, stackFilePath, values) if err != nil { return errors.Errorf("Failed to read stack file %s in %s %w", stackFilePath, stackSourceDir, err) } stackTargetDir := filepath.Join(stackSourceDir, StackDir) genOpts := generateOpts{ rootWorkingDir: pctx.RootWorkingDir, logShowAbsPaths: pctx.Writers.LogShowAbsPaths, sourceMap: pctx.SourceMap, noStackValidate: pctx.NoStackValidate, stackConfigPath: pctx.TerragruntStackConfigPath, } if err := generateUnits(ctx, l, genOpts, pool, stackFilePath, stackSourceDir, stackTargetDir, stackFile.Units); err != nil { return err } if err := generateStacks(ctx, l, genOpts, pool, stackFilePath, stackSourceDir, stackTargetDir, stackFile.Stacks); err != nil { return err } return nil } // generateOpts holds the subset of options needed for stack/unit generation. type generateOpts struct { sourceMap map[string]string rootWorkingDir string stackConfigPath string logShowAbsPaths bool noStackValidate bool } // generateUnits iterates through a slice of Unit objects, generating each one by copying // source files to their destination paths and writing unit-specific values. // It logs the generating progress and returns any errors encountered during the operation. func generateUnits(ctx context.Context, l log.Logger, opts generateOpts, pool *worker.Pool, sourceFile, sourceDir, targetDir string, units []*Unit) error { for _, unit := range units { pool.Submit(func() error { item := componentToGenerate{ sourceDir: sourceDir, targetDir: targetDir, name: unit.Name, path: unit.Path, source: unit.Source, values: unit.Values, noStack: unit.NoStack != nil && *unit.NoStack, noValidation: unit.NoValidation != nil && *unit.NoValidation, kind: unitKind, } l.Infof("Generating unit %s from %s", unit.Name, util.RelPathForLog(opts.rootWorkingDir, sourceFile, opts.logShowAbsPaths)) return telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate_unit", map[string]any{ "stack_file": sourceFile, "unit_name": unit.Name, "unit_source": unit.Source, "unit_path": unit.Path, }, func(ctx context.Context) error { return generateComponent(ctx, l, opts, &item) }) }) } return nil } // generateStacks generates each stack by resolving its destination path and copying files from the source. // It logs each operation and returns early if any error is encountered. func generateStacks(ctx context.Context, l log.Logger, opts generateOpts, pool *worker.Pool, sourceFile, sourceDir, targetDir string, stacks []*Stack) error { for _, stack := range stacks { pool.Submit(func() error { item := componentToGenerate{ sourceDir: sourceDir, targetDir: targetDir, name: stack.Name, path: stack.Path, source: stack.Source, noStack: stack.NoStack != nil && *stack.NoStack, noValidation: stack.NoValidation != nil && *stack.NoValidation, values: stack.Values, kind: stackKind, } l.Infof("Generating stack %s from %s", stack.Name, util.RelPathForLog(opts.rootWorkingDir, sourceFile, opts.logShowAbsPaths)) return telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate_stack", map[string]any{ "stack_file": sourceFile, "stack_name": stack.Name, "stack_source": stack.Source, "stack_path": stack.Path, }, func(ctx context.Context) error { return generateComponent(ctx, l, opts, &item) }) }) } return nil } type componentKind int const ( unitKind componentKind = iota stackKind ) // componentToGenerate represents an item of work for generating a stack or unit. // It contains information about the source and target directories, the name and path of the item, the source URL or path, // and any associated values that need to be generated. type componentToGenerate struct { values *cty.Value sourceDir string targetDir string name string path string source string noStack bool noValidation bool kind componentKind } // generateComponent copies files from the source directory to the target destination and generates a corresponding values file. func generateComponent(ctx context.Context, l log.Logger, opts generateOpts, cmp *componentToGenerate) error { source := cmp.source // Adjust source path using the provided source mapping configuration if available source, err := adjustSourceWithMap(opts.sourceMap, source, opts.stackConfigPath) if err != nil { return errors.Errorf("failed to adjust source %s: %w", cmp.source, err) } if filepath.IsAbs(cmp.path) { return errors.Errorf("path %s must be relative", cmp.path) } kindStr := "unit" if cmp.kind == stackKind { kindStr = "stack" } // building destination path based on target directory dest := filepath.Join(cmp.targetDir, cmp.path) // validate destination path is within the stack directory absDest := filepath.Clean(dest) absStackDir := filepath.Clean(cmp.targetDir) // validate that the destination path is within the stack directory if !strings.HasPrefix(absDest, absStackDir) { return errors.Errorf("%s destination path '%s' is outside of the stack directory '%s'", cmp.name, absDest, absStackDir) } if cmp.noStack { // for noStack components, we copy the files to the base directory of the target directory dest = filepath.Join(filepath.Dir(cmp.targetDir), cmp.path) } l.Debugf("Generating: %s (%s) to %s", cmp.name, source, dest) if err := copyFiles(ctx, l, cmp.name, cmp.sourceDir, source, dest); err != nil { return errors.Errorf( "Failed to fetch %s %s\n"+ " Source: %s\n"+ " Destination: %s\n\n"+ "Troubleshooting:\n"+ " 1. Check if your source path is correct relative to the stack file location\n"+ " 2. Verify the units or stacks directory exists at the expected location\n"+ " 3. Ensure you have proper permissions to read from source and write to destination\n\n"+ "Original error: %w", kindStr, cmp.name, source, dest, err, ) } skipValidation := false if cmp.noStack { l.Debugf("Skipping validation for %s %s due to no_stack flag", kindStr, cmp.name) skipValidation = true } if cmp.noValidation { l.Debugf("Skipping validation for %s %s due to no_validation flag", kindStr, cmp.name) skipValidation = true } if !skipValidation { // validate what was copied to the destination, don't do validation for special noStack components expectedFile := DefaultTerragruntConfigPath if cmp.kind == stackKind { expectedFile = DefaultStackFile } if err := validateTargetDir(kindStr, cmp.name, dest, expectedFile); err != nil { if opts.noStackValidate { // print warning if validation is skipped l.Warnf("Suppressing validation error for %s %s at path %s: expected %s to generate with %s file at root of generated directory.", kindStr, cmp.name, cmp.targetDir, kindStr, expectedFile) } else { return errors.Errorf("Validation failed for %s %s at path %s: expected %s to generate with %s file at root of generated directory.", kindStr, cmp.name, cmp.targetDir, kindStr, expectedFile) } } } // generate values file if err := writeValues(l, cmp.values, dest); err != nil { return errors.Errorf("failed to write values %v %w", cmp.name, err) } return nil } // copyFiles copies files or directories from a source to a destination path. // // The function checks if the source is local or remote. If local, it copies the // contents of the source directory to the destination. If remote, it fetches the // source and stores it in the destination directory. func copyFiles(ctx context.Context, l log.Logger, identifier, sourceDir, src, dest string) error { if isLocal(l, sourceDir, src) { // check if src is absolute path, if not, join with sourceDir var localSrc string if filepath.IsAbs(src) { localSrc = src } else { localSrc = filepath.Join(sourceDir, src) } localSrc = filepath.Clean(localSrc) if err := util.CopyFolderContentsWithFilter(l, localSrc, dest, manifestName, func(absolutePath string) bool { return true }); err != nil { return errors.Errorf("Failed to copy %s to %s %w", localSrc, dest, err) } } else { if err := os.MkdirAll(dest, os.ModePerm); err != nil { return errors.Errorf("Failed to create directory %s for %s %w", dest, identifier, err) } if _, err := getter.GetAny(ctx, dest, src); err != nil { return errors.Errorf("Failed to fetch %s %s for %s %w", src, dest, identifier, err) } } return nil } // isLocal determines if a given source path is local or remote. // // It checks if the provided source file exists locally. If not, it checks if // the path is relative to the working directory. If that also fails, the function // attempts to detect the source's getter type and recognizes if it is a file URL. func isLocal(l log.Logger, workingDir, src string) bool { // check initially if the source is a local file if util.FileExists(src) { return true } src = filepath.Join(workingDir, src) if util.FileExists(src) { return true } // check path through getters req := &getter.Request{ Src: src, } for _, g := range getter.Getters { recognized, err := getter.Detect(req, g) if err != nil { l.Debugf("Error detecting getter for %s: %v", src, err) continue } if recognized { break } } return strings.HasPrefix(req.Src, "file://") } // ReadOutputs retrieves the OpenTofu/Terraform output JSON for this unit, converts it into a map of cty.Values, // and logs the operation for debugging. It returns early in case of any errors during retrieval or conversion. func (u *Unit) ReadOutputs(ctx context.Context, l log.Logger, pctx *ParsingContext, unitDir string) (map[string]cty.Value, error) { configPath := filepath.Join(unitDir, DefaultTerragruntConfigPath) l.Debugf("Getting output from unit %s in %s", u.Name, unitDir) jsonBytes, err := getOutputJSONWithCaching(ctx, pctx, l, configPath) if err != nil { return nil, errors.New(err) } outputMap, err := TerraformOutputJSONToCtyValueMap(configPath, jsonBytes) if err != nil { return nil, errors.New(err) } return outputMap, nil } // ReadStackConfigFile reads and parses a Terragrunt stack configuration file from the given path. // It creates a parsing context, processes locals, and decodes the file into a StackConfig struct. // Validation is performed on the resulting config, and any encountered errors cause an early return. func ReadStackConfigFile(ctx context.Context, l log.Logger, pctx *ParsingContext, filePath string, values *cty.Value) (*StackConfig, error) { l.Debugf("Reading Terragrunt stack config file at %s", filePath) stackPctx := pctx.Clone() stackPctx.TerragruntConfigPath = filePath stackPctx.OriginalTerragruntConfigPath = filePath file, err := hclparse.NewParser(stackPctx.ParserOptions...).ParseFromFile(filePath) if err != nil { return nil, errors.New(err) } return ParseStackConfig(ctx, l, stackPctx, file, values) } // ReadStackConfigString reads and parses a Terragrunt stack configuration from a string. func ReadStackConfigString( ctx context.Context, l log.Logger, pctx *ParsingContext, configPath string, configString string, values *cty.Value, ) (*StackConfig, error) { if values != nil { pctx = pctx.WithValues(values) } hclFile, err := hclparse.NewParser(pctx.ParserOptions...).ParseFromString(configString, configPath) if err != nil { return nil, errors.New(err) } return ParseStackConfig(ctx, l, pctx, hclFile, values) } // ParseStackConfig parses the stack configuration from the given file and values. func ParseStackConfig(ctx context.Context, l log.Logger, parser *ParsingContext, file *hclparse.File, values *cty.Value) (*StackConfig, error) { if values != nil { parser = parser.WithValues(values) } if err := processLocals(ctx, l, parser, file); err != nil { return nil, errors.New(err) } evalParsingContext, err := createTerragruntEvalContext(ctx, parser, l, file.ConfigPath) if err != nil { return nil, errors.New(err) } config := &StackConfigFile{} if decodeErr := file.Decode(config, evalParsingContext); decodeErr != nil { return nil, errors.New(decodeErr) } localsParsed := map[string]any{} if parser.Locals != nil { localsParsed, err = ctyhelper.ParseCtyValueToMap(*parser.Locals) if err != nil { return nil, errors.New(err) } } stackConfig := &StackConfig{ Locals: localsParsed, Stacks: config.Stacks, Units: config.Units, } if err := ValidateStackConfig(config); err != nil { return nil, errors.New(err) } return stackConfig, nil } // writeValues generates and writes values to a terragrunt.values.hcl file in the specified directory. func writeValues(l log.Logger, values *cty.Value, directory string) error { if values == nil { l.Debugf("No values to write in %s", directory) return nil } // Avoid panics if the provided values are in unsupported format if values.IsNull() { l.Debugf("Skipping writing values in %s: values is null", directory) return nil } if !values.IsWhollyKnown() { l.Debugf("Skipping writing values in %s: values are not fully known", directory) return nil } valType := values.Type() if !valType.IsObjectType() && !valType.IsMapType() { return errors.Errorf("writeValues: expected object or map, got %s", valType.FriendlyName()) } if directory == "" { return errors.New("writeValues: unit directory path cannot be empty") } if err := os.MkdirAll(directory, unitDirPerm); err != nil { return errors.Errorf("failed to create directory %s: %w", directory, err) } l.Debugf("Writing values file in %s", directory) filePath := filepath.Join(directory, valuesFile) file := hclwrite.NewEmptyFile() body := file.Body() body.AppendUnstructuredTokens([]*hclwrite.Token{ { Type: hclsyntax.TokenComment, Bytes: []byte("# Auto-generated by the terragrunt.stack.hcl file by Terragrunt. Do not edit manually\n"), }, }) // Sort keys for deterministic output valueMap := values.AsValueMap() keys := make([]string, 0, len(valueMap)) for key := range valueMap { keys = append(keys, key) } // Sort keys alphabetically sort.Strings(keys) for _, key := range keys { body.SetAttributeValue(key, valueMap[key]) } if err := os.WriteFile(filePath, file.Bytes(), valueFilePerm); err != nil { return errors.Errorf("failed to write values file %s: %w", filePath, err) } return nil } // ReadValues reads values from the terragrunt.values.hcl file in the specified directory. func ReadValues(ctx context.Context, pctx *ParsingContext, l log.Logger, directory string) (*cty.Value, error) { if directory == "" { return nil, errors.New("ReadValues: directory path cannot be empty") } filePath := filepath.Join(directory, valuesFile) if util.FileNotExists(filePath) { return nil, nil } l.Debugf("Reading Terragrunt stack values file at %s", filePath) file, err := hclparse.NewParser(pctx.ParserOptions...).ParseFromFile(filePath) if err != nil { return nil, errors.New(err) } evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, errors.New(err) } values := map[string]cty.Value{} if err := file.Decode(&values, evalParsingContext); err != nil { return nil, errors.New(err) } result := cty.ObjectVal(values) return &result, nil } // processLocals processes the locals block in the stack file. func processLocals(ctx context.Context, l log.Logger, parser *ParsingContext, file *hclparse.File) error { localsBlock, err := file.Blocks(MetadataLocals, false) if err != nil { return errors.New(err) } if len(localsBlock) == 0 { return nil } if len(localsBlock) > 1 { return errors.New(fmt.Sprintf("up to one locals block is allowed per stack file, but found %d in %s", len(localsBlock), file.ConfigPath)) } attrs, err := localsBlock[0].JustAttributes() if err != nil { return errors.New(err) } evaluatedLocals := map[string]cty.Value{} evaluated := true for iterations := 0; len(attrs) > 0 && evaluated; iterations++ { if iterations > MaxIter { // Reached maximum supported iterations, which is most likely an infinite loop bug so cut the iteration // short and return an error. return errors.New(MaxIterError{}) } var evalErr error attrs, evaluatedLocals, evaluated, evalErr = attemptEvaluateLocals( ctx, parser, l, file, attrs, evaluatedLocals, ) if evalErr != nil { l.Debugf("Encountered error while evaluating locals in file %s", util.RelPathForLog(parser.RootWorkingDir, file.ConfigPath, parser.Writers.LogShowAbsPaths)) return errors.New(evalErr) } } localsAsCtyVal, err := ConvertValuesMapToCtyVal(evaluatedLocals) if err != nil { return errors.New(err) } parser.Locals = &localsAsCtyVal return nil } // validateTargetDir target destination directory. func validateTargetDir(kind, name, destDir, expectedFile string) error { expectedPath := filepath.Join(destDir, expectedFile) info, err := os.Stat(expectedPath) if err != nil { return fmt.Errorf("%s '%s': expected file '%s' not found in target directory '%s': %w", kind, name, expectedFile, destDir, err) } if info.IsDir() { return fmt.Errorf("%s '%s': expected file '%s' is a directory, not a file", kind, name, expectedFile) } return nil } // GetUnitDir returns the directory path for a unit based on its no_dot_terragrunt_stack setting. func GetUnitDir(dir string, unit *Unit) string { if unit.NoStack != nil && *unit.NoStack { return filepath.Join(dir, unit.Path) } return filepath.Join(dir, StackDir, unit.Path) } ================================================ FILE: pkg/config/stack_test.go ================================================ package config_test import ( "os" "path/filepath" "strings" "testing" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseTerragruntStackConfig(t *testing.T) { t.Parallel() cfg := ` locals { project = "my-project" } unit "unit1" { source = "units/app1" path = "unit1" } stack "projects" { source = "../projects" path = "projects" } ` ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) terragruntStackConfig, err := config.ReadStackConfigString(ctx, logger.CreateLogger(), pctx, config.DefaultStackFile, cfg, nil) require.NoError(t, err) assert.NotNil(t, terragruntStackConfig) assert.NotNil(t, terragruntStackConfig.Locals) assert.Len(t, terragruntStackConfig.Locals, 1) assert.Equal(t, "my-project", terragruntStackConfig.Locals["project"]) assert.NotNil(t, terragruntStackConfig.Units) assert.Len(t, terragruntStackConfig.Units, 1) unit := terragruntStackConfig.Units[0] assert.Equal(t, "unit1", unit.Name) assert.Equal(t, "units/app1", unit.Source) assert.Equal(t, "unit1", unit.Path) assert.Nil(t, unit.NoStack) assert.NotNil(t, terragruntStackConfig.Stacks) assert.Len(t, terragruntStackConfig.Stacks, 1) stack := terragruntStackConfig.Stacks[0] assert.Equal(t, "projects", stack.Name) assert.Equal(t, "../projects", stack.Source) assert.Equal(t, "projects", stack.Path) assert.Nil(t, stack.NoStack) } func TestParseTerragruntStackConfigComplex(t *testing.T) { t.Parallel() cfg := ` locals { project = "my-project" env = "dev" } unit "unit1" { source = "units/app1" path = "unit1" no_dot_terragrunt_stack = true values = { name = "app1" port = 8080 } } unit "unit2" { source = "units/app2" path = "unit2" no_dot_terragrunt_stack = false values = { name = "app2" port = 9090 } } stack "projects" { source = "../projects" path = "projects" values = { region = "us-west-2" } } stack "network" { source = "../network" path = "network" no_dot_terragrunt_stack = true } ` ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) terragruntStackConfig, err := config.ReadStackConfigString(ctx, logger.CreateLogger(), pctx, config.DefaultStackFile, cfg, nil) require.NoError(t, err) // Check that config is not nil assert.NotNil(t, terragruntStackConfig) assert.NotNil(t, terragruntStackConfig.Locals) assert.Len(t, terragruntStackConfig.Locals, 2) assert.Equal(t, "my-project", terragruntStackConfig.Locals["project"]) assert.Equal(t, "dev", terragruntStackConfig.Locals["env"]) assert.NotNil(t, terragruntStackConfig.Units) assert.Len(t, terragruntStackConfig.Units, 2) unit1 := terragruntStackConfig.Units[0] assert.Equal(t, "unit1", unit1.Name) assert.Equal(t, "units/app1", unit1.Source) assert.Equal(t, "unit1", unit1.Path) assert.NotNil(t, unit1.NoStack) assert.True(t, *unit1.NoStack) assert.NotNil(t, unit1.Values) unit2 := terragruntStackConfig.Units[1] assert.Equal(t, "unit2", unit2.Name) assert.Equal(t, "units/app2", unit2.Source) assert.Equal(t, "unit2", unit2.Path) assert.NotNil(t, unit2.NoStack) assert.False(t, *unit2.NoStack) assert.NotNil(t, unit2.Values) assert.NotNil(t, terragruntStackConfig.Stacks) assert.Len(t, terragruntStackConfig.Stacks, 2) stack1 := terragruntStackConfig.Stacks[0] assert.Equal(t, "projects", stack1.Name) assert.Equal(t, "../projects", stack1.Source) assert.Equal(t, "projects", stack1.Path) assert.Nil(t, stack1.NoStack) assert.NotNil(t, stack1.Values) stack2 := terragruntStackConfig.Stacks[1] assert.Equal(t, "network", stack2.Name) assert.Equal(t, "../network", stack2.Source) assert.Equal(t, "network", stack2.Path) assert.NotNil(t, stack2.NoStack) assert.True(t, *stack2.NoStack) } func TestParseTerragruntStackConfigInvalidSyntax(t *testing.T) { t.Parallel() invalidCfg := ` locals { project = "my-project } ` ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath) _, err := config.ReadStackConfigString(ctx, logger.CreateLogger(), pctx, config.DefaultStackFile, invalidCfg, nil) require.Error(t, err) assert.Contains(t, err.Error(), "Invalid multi-line string") } func TestWriteValuesSortsKeys(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) valuesFilePath := setupTestFiles(t, tmpDir) // Helper function to read and return the values file content readValuesFile := func() string { content, err := os.ReadFile(valuesFilePath) require.NoError(t, err) return string(content) } // Run multiple generations to test for deterministic behavior const numIterations = 5 generationContents := make([]string, 0, numIterations) for iteration := range numIterations { // Clean up any existing stack directory stackDir := filepath.Join(tmpDir, ".terragrunt-stack") os.RemoveAll(stackDir) // Generate the stack _, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --working-dir "+tmpDir) require.NoError(t, err) require.FileExists(t, valuesFilePath) content := readValuesFile() generationContents = append(generationContents, content) t.Logf("Generation %d content:\n%s\n", iteration+1, content) } // Extract only the complex verification logic to reduce cyclomatic complexity verifyDeterministicSortedOutput(t, generationContents) } // setupTestFiles creates the test environment and returns the values file path. func setupTestFiles(t *testing.T, tmpDir string) string { t.Helper() // Create a test stack configuration with more values in non-alphabetical order stackConfig := ` unit "test_unit" { source = "./unit" path = "test_unit" values = { zzz_last = "should be last" aaa_first = "should be first" mmm_middle = "should be middle" beta = 42 gamma = true delta = ["a", "b"] zebra = "animal" alpha = "letter" omega = "end" charlie = "nato" } } ` // Create the stack file stackFilePath := filepath.Join(tmpDir, config.DefaultStackFile) err := os.WriteFile(stackFilePath, []byte(stackConfig), 0644) require.NoError(t, err) // Create a simple unit directory with minimal terragrunt config unitDir := filepath.Join(tmpDir, "unit") err = os.MkdirAll(unitDir, 0755) require.NoError(t, err) unitConfig := ` terraform { source = "." } ` unitConfigPath := filepath.Join(unitDir, config.DefaultTerragruntConfigPath) err = os.WriteFile(unitConfigPath, []byte(unitConfig), 0644) require.NoError(t, err) // Create a minimal main.tf in the unit mainTf := ` resource "local_file" "test" { content = "test" filename = "test.txt" } ` mainTfPath := filepath.Join(unitDir, "main.tf") err = os.WriteFile(mainTfPath, []byte(mainTf), 0644) require.NoError(t, err) return filepath.Join(tmpDir, ".terragrunt-stack", "test_unit", "terragrunt.values.hcl") } // verifyDeterministicSortedOutput checks that all generations are identical and sorted. func verifyDeterministicSortedOutput(t *testing.T, generationContents []string) { t.Helper() // Check if all generations produced identical output allIdentical := true for i := 1; i < len(generationContents); i++ { if generationContents[i] != generationContents[0] { allIdentical = false break } } if !allIdentical { t.Logf("Non-deterministic behavior detected! Generations produced different output:") for i, content := range generationContents { t.Logf("Generation %d:\n%s\n", i+1, content) } assert.True(t, allIdentical, "Stack generation should be deterministic - all runs should produce identical values files") return } t.Logf("All generations produced identical output - checking if it's sorted...") // Now test the actual content and ordering using the first generation contentStr := generationContents[0] // Check if the keys appear in alphabetical order keys := []string{"aaa_first", "alpha", "beta", "charlie", "delta", "gamma", "mmm_middle", "omega", "zebra", "zzz_last"} positions := make([]int, len(keys)) for i, key := range keys { positions[i] = strings.Index(contentStr, key) if positions[i] == -1 { t.Fatalf("Key %s not found in generated content", key) } } // Check if positions are in ascending order (alphabetical) keysInOrder := true for i := 1; i < len(positions); i++ { if positions[i] < positions[i-1] { keysInOrder = false break } } t.Logf("Key positions: %v", positions) t.Logf("Keys in alphabetical order: %v", keysInOrder) if !keysInOrder { assert.True(t, keysInOrder, "Keys should appear in alphabetical order for deterministic output") } else { t.Logf("Keys are in alphabetical order - sorting implementation is working!") } } func TestWriteValuesSkipsWhenNilOrNull(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) // Create two units: one without values, one with explicit null values stackConfig := ` unit "u1" { source = "./unit1" path = "u1" } unit "u2" { source = "./unit2" path = "u2" values = null } ` stackFilePath := filepath.Join(tmpDir, config.DefaultStackFile) require.NoError(t, os.WriteFile(stackFilePath, []byte(stackConfig), 0644)) // Unit 1 unit1Dir := filepath.Join(tmpDir, "unit1") require.NoError(t, os.MkdirAll(unit1Dir, 0755)) unit1Config := ` terraform { source = "." } ` unit1ConfigPath := filepath.Join(unit1Dir, config.DefaultTerragruntConfigPath) require.NoError(t, os.WriteFile(unit1ConfigPath, []byte(unit1Config), 0644)) unit1MainTf := "" require.NoError(t, os.WriteFile(filepath.Join(unit1Dir, "main.tf"), []byte(unit1MainTf), 0644)) // Unit 2 unit2Dir := filepath.Join(tmpDir, "unit2") require.NoError(t, os.MkdirAll(unit2Dir, 0755)) unit2Config := unit1Config unit2ConfigPath := filepath.Join(unit2Dir, config.DefaultTerragruntConfigPath) require.NoError(t, os.WriteFile(unit2ConfigPath, []byte(unit2Config), 0644)) require.NoError(t, os.WriteFile(filepath.Join(unit2Dir, "main.tf"), []byte(unit1MainTf), 0644)) // Generate the stack _, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --working-dir "+tmpDir) require.NoError(t, err) // Ensure values files are not created for both units valuesU1 := filepath.Join(tmpDir, ".terragrunt-stack", "u1", "terragrunt.values.hcl") valuesU2 := filepath.Join(tmpDir, ".terragrunt-stack", "u2", "terragrunt.values.hcl") assert.NoFileExists(t, valuesU1) assert.NoFileExists(t, valuesU2) } func TestWriteValuesRejectsNonObjectValues(t *testing.T) { t.Parallel() tmpDir := helpers.TmpDirWOSymlinks(t) stackConfig := ` unit "bad" { source = "./unit" path = "bad" values = 666 } ` stackFilePath := filepath.Join(tmpDir, config.DefaultStackFile) require.NoError(t, os.WriteFile(stackFilePath, []byte(stackConfig), 0644)) unitDir := filepath.Join(tmpDir, "unit") require.NoError(t, os.MkdirAll(unitDir, 0755)) unitConfig := ` terraform { source = "." } ` unitConfigPath := filepath.Join(unitDir, config.DefaultTerragruntConfigPath) require.NoError(t, os.WriteFile(unitConfigPath, []byte(unitConfig), 0644)) require.NoError(t, os.WriteFile(filepath.Join(unitDir, "main.tf"), []byte(""), 0644)) stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --working-dir "+tmpDir) if err == nil { // If no error, that's a failure for this test t.Fatalf("expected error when values is non-object, got none. stdout=%s stderr=%s", stdout, stderr) } combined := stdout + "\n" + stderr + "\n" + err.Error() assert.Contains(t, combined, "expected object or map") } ================================================ FILE: pkg/config/stack_validation.go ================================================ package config import ( "strings" "github.com/gruntwork-io/terragrunt/internal/errors" ) // ValidateStackConfig validates a StackConfigFile instance according to the rules: // - Unit name, source, and path shouldn't be empty // - Unit names should be unique // - Units shouldn't have duplicate paths // - Stack name, source, and path shouldn't be empty // - Stack names should be unique // - Stack shouldn't have duplicate paths func ValidateStackConfig(config *StackConfigFile) error { if config == nil { return errors.New("stack config cannot be nil") } // Check if we have any units or stacks if len(config.Units) == 0 && len(config.Stacks) == 0 { return errors.New("stack config must contain at least one unit or stack") } validationErrors := &errors.MultiError{} if err := validateUnits(config.Units); err != nil { validationErrors = validationErrors.Append(err) } if err := validateStacks(config.Stacks); err != nil { validationErrors = validationErrors.Append(err) } return validationErrors.ErrorOrNil() } // validateUnits validates all units in the configuration func validateUnits(units []*Unit) error { return validateConfigElementsGeneric(units, "unit", func(element any, i int) (string, string, string) { unit := element.(*Unit) return unit.Name, unit.Path, unit.Source }) } // validateStacks validates all stacks in the configuration func validateStacks(stacks []*Stack) error { return validateConfigElementsGeneric(stacks, "stack", func(element any, i int) (string, string, string) { stack := element.(*Stack) return stack.Name, stack.Path, stack.Source }) } // validateConfigElementsGeneric is a generic function to validate configuration elements // It takes a slice of elements, the element type name, and a function to extract name, path, and source from an element func validateConfigElementsGeneric(elements any, elementType string, getValues func(element any, index int) (name, path, source string)) error { validationErrors := &errors.MultiError{} var slice []any // Convert the slice to a slice of interface{} switch v := elements.(type) { case []*Unit: slice = make([]any, len(v)) for i, unit := range v { slice[i] = unit } case []*Stack: slice = make([]any, len(v)) for i, stack := range v { slice[i] = stack } default: return errors.New("unknown element type") } names := make(map[string]bool, len(slice)) paths := make(map[string]bool, len(slice)) for i, element := range slice { if element == nil { validationErrors = validationErrors.Append(errors.Errorf("%s at index %d is nil", elementType, i)) continue } name, path, source := getValues(element, i) name = strings.TrimSpace(name) path = strings.TrimSpace(path) source = strings.TrimSpace(source) // Validate name, source, and path if name == "" { validationErrors = validationErrors.Append(errors.Errorf("%s at index %d has empty name", elementType, i)) } if source == "" { validationErrors = validationErrors.Append(errors.Errorf("%s '%s' has empty source", elementType, name)) } if path == "" { validationErrors = validationErrors.Append(errors.Errorf("%s '%s' has empty path", elementType, name)) } // Check for duplicates if names[name] { validationErrors = validationErrors.Append(errors.Errorf("duplicate %s name found: '%s'", elementType, name)) } if paths[path] { validationErrors = validationErrors.Append(errors.Errorf("duplicate %s path found: '%s'", elementType, path)) } // Save non-empty values for uniqueness check if name != "" { names[name] = true } if path != "" { paths[path] = true } } return validationErrors.ErrorOrNil() } ================================================ FILE: pkg/config/stack_validation_test.go ================================================ package config_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/stretchr/testify/assert" ) func TestValidateStackConfig(t *testing.T) { t.Parallel() tests := []struct { name string config *config.StackConfigFile wantErr string }{ { name: "valid config", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "source1", Path: "path1", }, { Name: "unit2", Source: "source2", Path: "path2", }, }, }, wantErr: "", }, { name: "empty config", config: &config.StackConfigFile{ Units: []*config.Unit{}, }, wantErr: "stack config must contain at least one unit", }, { name: "empty unit name", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "", Source: "source1", Path: "path1", }, }, }, wantErr: "unit at index 0 has empty name", }, { name: "whitespace unit name", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: " ", Source: "source1", Path: "path1", }, }, }, wantErr: "unit at index 0 has empty name", }, { name: "empty unit source", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "", Path: "path1", }, }, }, wantErr: "unit 'unit1' has empty source", }, { name: "whitespace unit source", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: " ", Path: "path1", }, }, }, wantErr: "unit 'unit1' has empty source", }, { name: "empty unit path", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "source1", Path: "", }, }, }, wantErr: "unit 'unit1' has empty path", }, { name: "whitespace unit path", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "source1", Path: " ", }, }, }, wantErr: "unit 'unit1' has empty path", }, { name: "duplicate unit names", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "source1", Path: "path1", }, { Name: "unit1", Source: "source2", Path: "path2", }, }, }, wantErr: "duplicate unit name found: 'unit1'", }, { name: "duplicate unit paths", config: &config.StackConfigFile{ Units: []*config.Unit{ { Name: "unit1", Source: "source1", Path: "path1", }, { Name: "unit2", Source: "source2", Path: "path1", }, }, }, wantErr: "duplicate unit path found: 'path1'", }, { name: "valid config with stacks", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "source1", Path: "path1", }, { Name: "stack2", Source: "source2", Path: "path2", }, }, }, wantErr: "", }, { name: "empty stack name", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "", Source: "source1", Path: "path1", }, }, }, wantErr: "stack at index 0 has empty name", }, { name: "whitespace stack name", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: " ", Source: "source1", Path: "path1", }, }, }, wantErr: "stack at index 0 has empty name", }, { name: "empty stack source", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "", Path: "path1", }, }, }, wantErr: "stack 'stack1' has empty source", }, { name: "whitespace stack source", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: " ", Path: "path1", }, }, }, wantErr: "stack 'stack1' has empty source", }, { name: "empty stack path", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "source1", Path: "", }, }, }, wantErr: "stack 'stack1' has empty path", }, { name: "whitespace stack path", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "source1", Path: " ", }, }, }, wantErr: "stack 'stack1' has empty path", }, { name: "duplicate stack names", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "source1", Path: "path1", }, { Name: "stack1", Source: "source2", Path: "path2", }, }, }, wantErr: "duplicate stack name found: 'stack1'", }, { name: "duplicate stack paths", config: &config.StackConfigFile{ Stacks: []*config.Stack{ { Name: "stack1", Source: "source1", Path: "path1", }, { Name: "stack2", Source: "source2", Path: "path1", }, }, }, wantErr: "duplicate stack path found: 'path1'", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := config.ValidateStackConfig(tt.config) if tt.wantErr != "" { assert.Contains(t, err.Error(), tt.wantErr) } else { assert.NoError(t, err) } }) } } ================================================ FILE: pkg/config/telemetry.go ================================================ // Package config provides telemetry support for configuration parsing operations. package config import ( "context" "strings" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/log" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) // Telemetry operation names for config parsing operations. const ( TelemetryOpParseConfigFile = "parse_config_file" TelemetryOpParseBaseBlocks = "parse_base_blocks" TelemetryOpParseDependencies = "parse_dependencies" TelemetryOpParseDependency = "parse_dependency" TelemetryOpParseConfigDecode = "parse_config_decode" TelemetryOpParseIncludeMerge = "parse_include_merge" ) // Telemetry attribute keys for config parsing operations. const ( AttrConfigPath = "config_path" AttrWorkingDir = "working_dir" AttrIsPartial = "is_partial" AttrDecodeList = "decode_list" AttrCacheHit = "cache_hit" AttrIncludeFromChild = "include_from_child" AttrIncludeChildPath = "include_child_path" AttrHasIncludes = "has_includes" AttrIncludeCount = "include_count" AttrIncludePaths = "include_paths" AttrDependencyCount = "dependency_count" AttrDependencyNames = "dependency_names" AttrDependencyName = "dependency_name" AttrDependencyPath = "dependency_path" AttrLocalsCount = "locals_count" AttrLocalsNames = "locals_names" AttrFeatureFlagCount = "feature_flag_count" AttrFeatureFlagNames = "feature_flag_names" AttrSkipOutputs = "skip_outputs_resolution" ) // TraceParseConfigFile wraps a config file parsing operation with telemetry. func TraceParseConfigFile( ctx context.Context, configPath string, workingDir string, isPartial bool, decodeList []PartialDecodeSectionType, includeFromChild *IncludeConfig, cacheHit bool, fn func(ctx context.Context) error, ) error { attrs := map[string]any{ AttrConfigPath: configPath, AttrWorkingDir: workingDir, AttrIsPartial: isPartial, AttrCacheHit: cacheHit, AttrIncludeFromChild: includeFromChild != nil, } if len(decodeList) > 0 { attrs[AttrDecodeList] = formatDecodeList(decodeList) } if includeFromChild != nil { attrs[AttrIncludeChildPath] = includeFromChild.Path } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseConfigFile, attrs, fn) } // TraceParseBaseBlocks wraps base blocks parsing with telemetry. func TraceParseBaseBlocks( ctx context.Context, l log.Logger, configPath string, fn func(ctx context.Context) (*DecodedBaseBlocks, error), ) (*DecodedBaseBlocks, error) { var ( result *DecodedBaseBlocks resultErr error ) err := telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseBaseBlocks, map[string]any{ AttrConfigPath: configPath, }, func(childCtx context.Context) error { result, resultErr = fn(childCtx) return resultErr }) if err != nil { l.Warnf("Telemetry error during base blocks parsing: %v", err) } return result, resultErr } // TraceParseBaseBlocksResult adds result attributes to the current span from context. func TraceParseBaseBlocksResult( ctx context.Context, configPath string, baseBlocks *DecodedBaseBlocks, ) { span := trace.SpanFromContext(ctx) if span == nil || !span.IsRecording() { return } attrs := []attribute.KeyValue{ attribute.String(AttrConfigPath, configPath), } if baseBlocks != nil { // Include information if baseBlocks.TrackInclude != nil && len(baseBlocks.TrackInclude.CurrentList) > 0 { attrs = append(attrs, attribute.Bool(AttrHasIncludes, true), attribute.Int(AttrIncludeCount, len(baseBlocks.TrackInclude.CurrentList)), attribute.String(AttrIncludePaths, formatIncludePaths(baseBlocks.TrackInclude.CurrentList)), ) } else { attrs = append(attrs, attribute.Bool(AttrHasIncludes, false), attribute.Int(AttrIncludeCount, 0), ) } // Locals information if baseBlocks.Locals != nil && !baseBlocks.Locals.IsNull() { localsMap := baseBlocks.Locals.AsValueMap() attrs = append(attrs, attribute.Int(AttrLocalsCount, len(localsMap)), attribute.String(AttrLocalsNames, formatMapKeys(localsMap)), ) } else { attrs = append(attrs, attribute.Int(AttrLocalsCount, 0)) } // Feature flags information if baseBlocks.FeatureFlags != nil && !baseBlocks.FeatureFlags.IsNull() { flagsMap := baseBlocks.FeatureFlags.AsValueMap() attrs = append(attrs, attribute.Int(AttrFeatureFlagCount, len(flagsMap)), attribute.String(AttrFeatureFlagNames, formatMapKeys(flagsMap)), ) } else { attrs = append(attrs, attribute.Int(AttrFeatureFlagCount, 0)) } } span.SetAttributes(attrs...) } // TraceParseDependencies wraps dependency parsing with telemetry. func TraceParseDependencies( ctx context.Context, configPath string, skipOutputsResolution bool, dependencyCount int, dependencyNames []string, fn func(ctx context.Context) error, ) error { attrs := map[string]any{ AttrConfigPath: configPath, AttrSkipOutputs: skipOutputsResolution, AttrDependencyCount: dependencyCount, } if len(dependencyNames) > 0 { attrs[AttrDependencyNames] = strings.Join(dependencyNames, ",") } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseDependencies, attrs, fn) } // TraceParseDependency wraps individual dependency output resolution with telemetry. func TraceParseDependency( ctx context.Context, dependencyName string, dependencyPath string, fn func(ctx context.Context) error, ) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseDependency, map[string]any{ AttrDependencyName: dependencyName, AttrDependencyPath: dependencyPath, }, fn) } // TraceParseConfigDecode wraps config decoding with telemetry. func TraceParseConfigDecode( ctx context.Context, configPath string, fn func(ctx context.Context) error, ) error { return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseConfigDecode, map[string]any{ AttrConfigPath: configPath, }, fn) } // TraceParseIncludeMerge wraps include merging with telemetry. func TraceParseIncludeMerge( ctx context.Context, configPath string, includeCount int, includePaths []string, fn func(ctx context.Context) error, ) error { attrs := map[string]any{ AttrConfigPath: configPath, AttrIncludeCount: includeCount, } if len(includePaths) > 0 { attrs[AttrIncludePaths] = strings.Join(includePaths, ",") } return telemetry.TelemeterFromContext(ctx).Collect(ctx, TelemetryOpParseIncludeMerge, attrs, fn) } // formatDecodeList converts a slice of PartialDecodeSectionType to a comma-separated string. func formatDecodeList(decodeList []PartialDecodeSectionType) string { names := make([]string, 0, len(decodeList)) for _, section := range decodeList { names = append(names, partialDecodeSectionName(section)) } return strings.Join(names, ",") } // partialDecodeSectionName returns a human-readable name for a PartialDecodeSectionType. func partialDecodeSectionName(section PartialDecodeSectionType) string { switch section { case DependenciesBlock: return "dependencies" case DependencyBlock: return "dependency" case TerraformBlock: return "terraform" case TerraformSource: return "terraform_source" case TerragruntFlags: return "terragrunt_flags" case TerragruntVersionConstraints: return "version_constraints" case RemoteStateBlock: return "remote_state" case FeatureFlagsBlock: return "feature_flags" case EngineBlock: return "engine" case ExcludeBlock: return "exclude" case ErrorsBlock: return "errors" default: return "unknown" } } // formatIncludePaths extracts and formats include paths from a list of IncludeConfigs. func formatIncludePaths(includes IncludeConfigs) string { paths := make([]string, 0, len(includes)) for _, inc := range includes { if inc.Path != "" { paths = append(paths, inc.Path) } } return strings.Join(paths, ",") } // formatMapKeys extracts keys from a map and returns them as a comma-separated string. func formatMapKeys[V any](m map[string]V) string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return strings.Join(keys, ",") } ================================================ FILE: pkg/config/translate.go ================================================ package config import ( "github.com/gruntwork-io/terragrunt/internal/codegen" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // ToRunConfig translates a TerragruntConfig to a runcfg.RunConfig. // This is the primary method for converting config types to runner types. func (cfg *TerragruntConfig) ToRunConfig(l log.Logger) *runcfg.RunConfig { if cfg == nil { return nil } runCfg := &runcfg.RunConfig{ Terraform: translateTerraformConfig(cfg.Terraform, l), RemoteState: translateRemoteState(cfg.RemoteState), Exclude: translateExcludeConfig(cfg.Exclude), GenerateConfigs: translateGenerateConfigs(cfg.GenerateConfigs), Inputs: translateInputs(cfg.Inputs), IAMRole: cfg.GetIAMRoleOptions(), DownloadDir: cfg.DownloadDir, TerraformBinary: cfg.TerraformBinary, TerraformVersionConstraint: cfg.TerraformVersionConstraint, TerragruntVersionConstraint: cfg.TerragruntVersionConstraint, PreventDestroy: translatePreventDestroy(cfg.PreventDestroy), ProcessedIncludes: translateProcessedIncludes(cfg.ProcessedIncludes), Dependencies: translateModuleDependencies(cfg.Dependencies), Engine: translateEngineConfig(cfg.Engine), Errors: translateErrorsConfig(cfg.Errors), } return runCfg } // translateTerraformConfig converts config.TerraformConfig to runcfg.TerraformConfig. func translateTerraformConfig(tf *TerraformConfig, l log.Logger) runcfg.TerraformConfig { if tf == nil { return runcfg.TerraformConfig{} } var source string if tf.Source != nil { source = *tf.Source } var includeInCopy []string if tf.IncludeInCopy != nil { includeInCopy = *tf.IncludeInCopy } var excludeFromCopy []string if tf.ExcludeFromCopy != nil { excludeFromCopy = *tf.ExcludeFromCopy } noCopyTerraformLockFile := false if tf.CopyTerraformLockFile != nil { noCopyTerraformLockFile = !*tf.CopyTerraformLockFile } return runcfg.TerraformConfig{ Source: source, IncludeInCopy: includeInCopy, ExcludeFromCopy: excludeFromCopy, NoCopyTerraformLockFile: noCopyTerraformLockFile, ExtraArgs: translateExtraArgs(tf.ExtraArgs, l), BeforeHooks: translateHooks(tf.BeforeHooks), AfterHooks: translateHooks(tf.AfterHooks), ErrorHooks: translateErrorHooks(tf.ErrorHooks), } } // translateExtraArgs converts []TerraformExtraArguments to []runcfg.TerraformExtraArguments. func translateExtraArgs(args []TerraformExtraArguments, l log.Logger) []runcfg.TerraformExtraArguments { if args == nil { return nil } result := make([]runcfg.TerraformExtraArguments, len(args)) for i, arg := range args { varFiles := computeVarFiles(arg.RequiredVarFiles, arg.OptionalVarFiles, l) var arguments []string if arg.Arguments != nil { arguments = *arg.Arguments } var requiredVarFiles []string if arg.RequiredVarFiles != nil { requiredVarFiles = *arg.RequiredVarFiles } var optionalVarFiles []string if arg.OptionalVarFiles != nil { optionalVarFiles = *arg.OptionalVarFiles } var envVars map[string]string if arg.EnvVars != nil { envVars = *arg.EnvVars } result[i] = runcfg.TerraformExtraArguments{ Name: arg.Name, Commands: arg.Commands, Arguments: arguments, RequiredVarFiles: requiredVarFiles, OptionalVarFiles: optionalVarFiles, VarFiles: varFiles, EnvVars: envVars, } } return result } // computeVarFiles returns a list of variable files, including required and optional files. func computeVarFiles(requiredVarFiles *[]string, optionalVarFiles *[]string, l log.Logger) []string { var varFiles []string // Include all specified RequiredVarFiles. if requiredVarFiles != nil { varFiles = append(varFiles, util.RemoveDuplicatesKeepLast(*requiredVarFiles)...) } // If OptionalVarFiles is specified, check for each file if it exists and if so, include in the var // files list. Note that it is possible that many files resolve to the same path, so we remove // duplicates. if optionalVarFiles != nil { for _, file := range util.RemoveDuplicatesKeepLast(*optionalVarFiles) { if util.FileExists(file) { varFiles = append(varFiles, file) } else { l.Debugf("Skipping var-file %s as it does not exist", file) } } } return varFiles } // translateHooks converts []Hook to []runcfg.Hook. func translateHooks(hooks []Hook) []runcfg.Hook { if hooks == nil { return nil } result := make([]runcfg.Hook, len(hooks)) for i, hook := range hooks { var workingDir string if hook.WorkingDir != nil { workingDir = *hook.WorkingDir } var runOnError bool if hook.RunOnError != nil { runOnError = *hook.RunOnError } ifCondition := true if hook.If != nil { ifCondition = *hook.If } var suppressStdout bool if hook.SuppressStdout != nil { suppressStdout = *hook.SuppressStdout } result[i] = runcfg.Hook{ Name: hook.Name, Commands: hook.Commands, Execute: hook.Execute, WorkingDir: workingDir, RunOnError: runOnError, If: ifCondition, SuppressStdout: suppressStdout, } } return result } // translateErrorHooks converts []ErrorHook to []runcfg.ErrorHook. func translateErrorHooks(hooks []ErrorHook) []runcfg.ErrorHook { if hooks == nil { return nil } result := make([]runcfg.ErrorHook, len(hooks)) for i, hook := range hooks { var workingDir string if hook.WorkingDir != nil { workingDir = *hook.WorkingDir } var suppressStdout bool if hook.SuppressStdout != nil { suppressStdout = *hook.SuppressStdout } result[i] = runcfg.ErrorHook{ Name: hook.Name, Commands: hook.Commands, Execute: hook.Execute, OnErrors: hook.OnErrors, WorkingDir: workingDir, SuppressStdout: suppressStdout, } } return result } // translateGenerateConfigs converts map[string]codegen.GenerateConfig to map[string]codegen.GenerateConfig. // Returns an empty map if the input is nil. func translateGenerateConfigs(generateConfigs map[string]codegen.GenerateConfig) map[string]codegen.GenerateConfig { if generateConfigs == nil { return make(map[string]codegen.GenerateConfig) } return generateConfigs } // translateInputs converts map[string]any to map[string]any. // Returns an empty map if the input is nil. func translateInputs(inputs map[string]any) map[string]any { if inputs == nil { return make(map[string]any) } return inputs } // translatePreventDestroy converts *bool to bool. func translatePreventDestroy(preventDestroy *bool) bool { if preventDestroy == nil { return false } return *preventDestroy } // translateRemoteState converts *remotestate.RemoteState to remotestate.RemoteState. func translateRemoteState(remoteState *remotestate.RemoteState) remotestate.RemoteState { if remoteState == nil { return remotestate.RemoteState{} } return *remoteState } // translateExcludeConfig converts *ExcludeConfig to runcfg.ExcludeConfig. func translateExcludeConfig(exclude *ExcludeConfig) runcfg.ExcludeConfig { if exclude == nil { return runcfg.ExcludeConfig{} } var excludeDependencies bool if exclude.ExcludeDependencies != nil { excludeDependencies = *exclude.ExcludeDependencies } var noRun bool if exclude.NoRun != nil { noRun = *exclude.NoRun } return runcfg.ExcludeConfig{ If: exclude.If, Actions: exclude.Actions, ExcludeDependencies: excludeDependencies, NoRun: noRun, } } // translateIncludeConfigs converts IncludeConfigsMap to map[string]runcfg.IncludeConfig. func translateIncludeConfigs(includes IncludeConfigsMap) map[string]runcfg.IncludeConfig { if includes == nil { return nil } result := make(map[string]runcfg.IncludeConfig, len(includes)) for name, inc := range includes { var expose bool if inc.Expose != nil { expose = *inc.Expose } var mergeStrategy string if inc.MergeStrategy != nil { mergeStrategy = *inc.MergeStrategy } result[name] = runcfg.IncludeConfig{ Name: inc.Name, Path: inc.Path, Expose: expose, MergeStrategy: mergeStrategy, } } return result } // translateProcessedIncludes converts IncludeConfigsMap to map[string]runcfg.IncludeConfig. // Returns an empty map if the input is nil. func translateProcessedIncludes(includes IncludeConfigsMap) map[string]runcfg.IncludeConfig { result := translateIncludeConfigs(includes) if result == nil { return make(map[string]runcfg.IncludeConfig) } return result } // translateModuleDependencies converts *ModuleDependencies to runcfg.ModuleDependencies. func translateModuleDependencies(deps *ModuleDependencies) runcfg.ModuleDependencies { if deps == nil { return runcfg.ModuleDependencies{} } return runcfg.ModuleDependencies{ Paths: deps.Paths, } } // translateEngineConfig converts *EngineConfig to runcfg.EngineConfig. func translateEngineConfig(engine *EngineConfig) runcfg.EngineConfig { if engine == nil { return runcfg.EngineConfig{} } var version string if engine.Version != nil { version = *engine.Version } var engineType string if engine.Type != nil { engineType = *engine.Type } return runcfg.EngineConfig{ Enable: true, Source: engine.Source, Version: version, Type: engineType, Meta: engine.Meta, } } // translateErrorsConfig converts *ErrorsConfig to runcfg.ErrorsConfig. func translateErrorsConfig(errors *ErrorsConfig) runcfg.ErrorsConfig { if errors == nil { return runcfg.ErrorsConfig{} } return runcfg.ErrorsConfig{ Retry: translateRetryBlocks(errors.Retry), Ignore: translateIgnoreBlocks(errors.Ignore), } } // translateRetryBlocks converts []*RetryBlock to []*runcfg.RetryBlock. func translateRetryBlocks(blocks []*RetryBlock) []*runcfg.RetryBlock { if blocks == nil { return nil } result := make([]*runcfg.RetryBlock, len(blocks)) for i, block := range blocks { if block == nil { continue } result[i] = &runcfg.RetryBlock{ Label: block.Label, RetryableErrors: block.RetryableErrors, MaxAttempts: block.MaxAttempts, SleepIntervalSec: block.SleepIntervalSec, } } return result } // translateIgnoreBlocks converts []*IgnoreBlock to []*runcfg.IgnoreBlock. func translateIgnoreBlocks(blocks []*IgnoreBlock) []*runcfg.IgnoreBlock { if blocks == nil { return nil } result := make([]*runcfg.IgnoreBlock, len(blocks)) for i, block := range blocks { if block == nil { continue } result[i] = &runcfg.IgnoreBlock{ Label: block.Label, IgnorableErrors: block.IgnorableErrors, Message: block.Message, Signals: block.Signals, } } return result } ================================================ FILE: pkg/config/util.go ================================================ package config import ( "path/filepath" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/log" ) // CopyLockFile copies the lock file from the source folder to the destination folder. // // Terraform 0.14 now generates a lock file when you run `terraform init`. // If any such file exists, this function will copy the lock file to the destination folder func CopyLockFile(l log.Logger, rootWorkingDir string, logShowAbsPaths bool, sourceFolder, destinationFolder string) error { sourceLockFilePath := filepath.Join(sourceFolder, tf.TerraformLockFile) destinationLockFilePath := filepath.Join(destinationFolder, tf.TerraformLockFile) if util.FileExists(sourceLockFilePath) { l.Debugf( "Copying lock file from %s to %s", util.RelPathForLog( rootWorkingDir, sourceLockFilePath, logShowAbsPaths, ), util.RelPathForLog( rootWorkingDir, destinationLockFilePath, logShowAbsPaths, ), ) return util.CopyFile(sourceLockFilePath, destinationLockFilePath) } return nil } ================================================ FILE: pkg/config/variable.go ================================================ package config import ( "encoding/json" "fmt" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" ) // ParsedVariable structure with input name, default value and description. type ParsedVariable struct { Name string Description string Type string DefaultValue string DefaultValuePlaceholder string } // ParseVariables - parse variables from tf files. func ParseVariables(l log.Logger, experiments experiment.Experiments, strictControls strict.Controls, directoryPath string) ([]*ParsedVariable, error) { walkWithSymlinks := experiments.Evaluate(experiment.Symlinks) // list all tf files tfFiles, err := util.ListTfFiles(directoryPath, walkWithSymlinks) if err != nil { return nil, errors.New(err) } parser := hclparse.NewParser(DefaultParserOptions(l, strictControls)...) // iterate over files and parse variables. var parsedInputs []*ParsedVariable for _, tfFile := range tfFiles { if _, err := parser.ParseFromFile(tfFile); err != nil { return nil, err } } for _, file := range parser.Files() { ctx := &hcl.EvalContext{} if body, ok := file.Body.(*hclsyntax.Body); ok { for _, block := range body.Blocks { if block.Type == "variable" { if len(block.Labels[0]) > 0 { // extract variable attributes name := block.Labels[0] var descriptionAttrText string descriptionAttr, err := readBlockAttribute(ctx, block, "description") if err != nil { l.Warnf("Failed to read descriptionAttr for %s %v", name, err) descriptionAttr = nil } if descriptionAttr != nil { descriptionAttrText = descriptionAttr.AsString() } else { descriptionAttrText = fmt.Sprintf("(variable %s did not define a description)", name) } var typeAttrText string typeAttr, err := readBlockAttribute(ctx, block, "type") if err != nil { l.Warnf("Failed to read type attribute for %s %v", name, err) } if typeAttr != nil { typeAttrText = typeAttr.AsString() } else { typeAttrText = fmt.Sprintf("(variable %s does not define a type)", name) } defaultValue, err := readBlockAttribute(ctx, block, "default") if err != nil { l.Warnf("Failed to read default value for %s %v", name, err) defaultValue = nil } defaultValueText := "" if defaultValue != nil { jsonBytes, err := ctyjson.Marshal(*defaultValue, cty.DynamicPseudoType) if err != nil { return nil, errors.New(err) } var ctyJSONOutput ctyJSONValue if err := json.Unmarshal(jsonBytes, &ctyJSONOutput); err != nil { return nil, errors.New(err) } jsonBytes, err = json.Marshal(ctyJSONOutput.Value) if err != nil { return nil, errors.New(err) } defaultValueText = string(jsonBytes) } input := &ParsedVariable{ Name: name, Type: typeAttrText, Description: descriptionAttrText, DefaultValue: defaultValueText, DefaultValuePlaceholder: generateDefaultValue(typeAttrText), } parsedInputs = append(parsedInputs, input) } } } } } return parsedInputs, nil } // generateDefaultValue - generate hcl default value // HCL type of variable https://developer.hashicorp.com/packer/docs/templates/hcl_templates/variables#type-constraints func generateDefaultValue(variableType string) string { switch variableType { case "number": return "0" case "bool": return "false" case "list": return "[]" case "map": return "{}" case "object": return "{}" } // fallback to empty value return "\"\"" } type ctyJSONValue struct { Value any `json:"Value"` Type any `json:"Type"` } // readBlockAttribute - hcl block attribute. func readBlockAttribute(ctx *hcl.EvalContext, block *hclsyntax.Block, name string) (*cty.Value, error) { if attr, ok := block.Body.Attributes[name]; ok { if attr.Expr != nil { if call, ok := attr.Expr.(*hclsyntax.FunctionCallExpr); ok { result := cty.StringVal(call.Name) return &result, nil } // check if first var is traversal if len(attr.Expr.Variables()) > 0 { v := attr.Expr.Variables()[0] // check if variable is traversal if varTr, ok := v[0].(hcl.TraverseRoot); ok { result := cty.StringVal(varTr.Name) return &result, nil } } value, err := attr.Expr.Value(ctx) if err != nil { return nil, err } return &value, nil } } return nil, nil } ================================================ FILE: pkg/config/variable_test.go ================================================ package config_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestScanVariables(t *testing.T) { t.Parallel() inputs, err := config.ParseVariables(logger.CreateLogger(), experiment.NewExperiments(), controls.New(), "../../test/fixtures/inputs") require.NoError(t, err) assert.Len(t, inputs, 11) varByName := map[string]*config.ParsedVariable{} for _, input := range inputs { varByName[input.Name] = input } assert.Equal(t, "string", varByName["string"].Type) assert.Equal(t, "\"\"", varByName["string"].DefaultValuePlaceholder) assert.Equal(t, "bool", varByName["bool"].Type) assert.Equal(t, "false", varByName["bool"].DefaultValuePlaceholder) assert.Equal(t, "number", varByName["number"].Type) assert.Equal(t, "0", varByName["number"].DefaultValuePlaceholder) assert.Equal(t, "object", varByName["object"].Type) assert.Equal(t, "{}", varByName["object"].DefaultValuePlaceholder) assert.Equal(t, "map", varByName["map_bool"].Type) assert.Equal(t, "{}", varByName["map_bool"].DefaultValuePlaceholder) assert.Equal(t, "list", varByName["list_bool"].Type) assert.Equal(t, "[]", varByName["list_bool"].DefaultValuePlaceholder) } func TestScanDefaultVariables(t *testing.T) { t.Parallel() inputs, err := config.ParseVariables(logger.CreateLogger(), experiment.NewExperiments(), controls.New(), "../../test/fixtures/inputs-defaults") require.NoError(t, err) assert.Len(t, inputs, 11) varByName := map[string]*config.ParsedVariable{} for _, input := range inputs { varByName[input.Name] = input } assert.Equal(t, "string", varByName["project_name"].Type) assert.Equal(t, "Project name", varByName["project_name"].Description) assert.Equal(t, "\"\"", varByName["project_name"].DefaultValuePlaceholder) assert.Equal(t, "(variable no_type_value_var does not define a type)", varByName["no_type_value_var"].Type) assert.Equal(t, "(variable no_type_value_var did not define a description)", varByName["no_type_value_var"].Description) assert.Equal(t, "\"\"", varByName["no_type_value_var"].DefaultValuePlaceholder) assert.Equal(t, "number", varByName["number_default"].Type) assert.Equal(t, "number variable with default", varByName["number_default"].Description) assert.Equal(t, "42", varByName["number_default"].DefaultValue) assert.Equal(t, "0", varByName["number_default"].DefaultValuePlaceholder) assert.Equal(t, "object", varByName["object_var"].Type) assert.JSONEq(t, "{\"num\":42,\"str\":\"default\"}", varByName["object_var"].DefaultValue) assert.Equal(t, "map", varByName["map_var"].Type) assert.JSONEq(t, "{\"key\":\"value42\"}", varByName["map_var"].DefaultValue) assert.Equal(t, "bool", varByName["enabled"].Type) assert.Equal(t, "true", varByName["enabled"].DefaultValue) assert.Equal(t, "Enable or disable the module", varByName["enabled"].Description) assert.Equal(t, "string", varByName["vpc"].Type) assert.Equal(t, "\"default-vpc\"", varByName["vpc"].DefaultValue) assert.Equal(t, "VPC to be used", varByName["vpc"].Description) } ================================================ FILE: pkg/log/context.go ================================================ package log import "context" const ( loggerContextKey ctxKey = iota ) type ctxKey byte func ContextWithLogger(ctx context.Context, logger Logger) context.Context { return context.WithValue(ctx, loggerContextKey, logger) } func LoggerFromContext(ctx context.Context) Logger { if val := ctx.Value(loggerContextKey); val != nil { if val, ok := val.(Logger); ok { return val } } return nil } ================================================ FILE: pkg/log/context_test.go ================================================ package log_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestContextWithLogger(t *testing.T) { t.Parallel() logger := log.New() ctx := log.ContextWithLogger(t.Context(), logger) retrieved := log.LoggerFromContext(ctx) require.NotNil(t, retrieved) assert.Equal(t, logger, retrieved) } func TestLoggerFromContextEmpty(t *testing.T) { t.Parallel() ctx := t.Context() retrieved := log.LoggerFromContext(ctx) assert.Nil(t, retrieved) } ================================================ FILE: pkg/log/external_test.go ================================================ // Tests in this file validate that the pkg/log package can be fully utilized as // an external dependency. Every scenario here imports only public packages // (pkg/log, pkg/log/format, pkg/log/format/placeholders, pkg/log/writer) and // never reaches into internal/ packages. If any of these tests fail to compile, // it signals a broken public API contract. package log_test import ( "bytes" "testing" "time" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/gruntwork-io/terragrunt/pkg/log/writer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestExternalLoggerLifecycle exercises the core create → configure → log → // clone workflow that an external consumer would follow. func TestExternalLoggerLifecycle(t *testing.T) { t.Parallel() buf := new(bytes.Buffer) logger := log.New( log.WithLevel(log.DebugLevel), log.WithOutput(buf), ) // Basic logging methods. logger.Info("info message") assert.Contains(t, buf.String(), "info message") buf.Reset() logger.Debugf("count=%d", 42) assert.Contains(t, buf.String(), "count=42") buf.Reset() // Clone and change level independently. child := logger.Clone() child.SetOptions(log.WithOutput(buf)) require.NoError(t, child.SetLevel("trace")) child.Trace("trace message") assert.Contains(t, buf.String(), "trace message") } // TestExternalLevelRoundTrip confirms that an external consumer can parse a // level string, inspect its various name forms, and marshal/unmarshal it. func TestExternalLevelRoundTrip(t *testing.T) { t.Parallel() level, err := log.ParseLevel("warn") require.NoError(t, err) assert.Equal(t, log.WarnLevel, level) assert.Equal(t, "warn", level.String()) assert.Equal(t, "wrn", level.ShortName()) assert.Equal(t, "w", level.TinyName()) data, err := level.MarshalText() require.NoError(t, err) var restored log.Level require.NoError(t, restored.UnmarshalText(data)) assert.Equal(t, level, restored) } // TestExternalAllLevelsEnumeration verifies that AllLevels is accessible and // that every level can be stringified. func TestExternalAllLevelsEnumeration(t *testing.T) { t.Parallel() assert.Len(t, log.AllLevels, 7) for _, lvl := range log.AllLevels { assert.NotEmpty(t, lvl.String()) assert.True(t, log.AllLevels.Contains(lvl)) } assert.False(t, log.AllLevels.Contains(log.Level(99))) } // TestExternalFieldsAndErrors confirms that WithField, WithFields, and // WithError return enriched loggers whose output contains the added metadata. func TestExternalFieldsAndErrors(t *testing.T) { t.Parallel() buf := new(bytes.Buffer) logger := log.New(log.WithLevel(log.InfoLevel), log.WithOutput(buf)) logger.WithField("component", "auth").Info("field check") assert.Contains(t, buf.String(), "component") buf.Reset() logger.WithFields(log.Fields{"a": 1, "b": 2}).Info("fields check") output := buf.String() assert.Contains(t, output, "a") assert.Contains(t, output, "b") buf.Reset() logger.WithError(assert.AnError).Info("error check") assert.Contains(t, buf.String(), assert.AnError.Error()) } // TestExternalContextPropagation stores a logger in a context and retrieves it, // the way middleware or request handlers would. func TestExternalContextPropagation(t *testing.T) { t.Parallel() logger := log.New(log.WithLevel(log.InfoLevel)) ctx := log.ContextWithLogger(t.Context(), logger) retrieved := log.LoggerFromContext(ctx) require.NotNil(t, retrieved) assert.Equal(t, log.InfoLevel, retrieved.Level()) assert.Nil(t, log.LoggerFromContext(t.Context())) } // TestExternalFormatterIntegration creates a Formatter from the format // subpackage, wires it into a logger, and verifies formatted output. func TestExternalFormatterIntegration(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(format.NewBareFormatPlaceholders()) fmtr.SetDisabledColors(true) buf := new(bytes.Buffer) logger := log.New( log.WithLevel(log.InfoLevel), log.WithOutput(buf), log.WithFormatter(fmtr), ) logger.Info("formatted output") assert.Contains(t, buf.String(), "formatted output") } // TestExternalParseFormatPresets confirms all four named format presets are // accessible via ParseFormat. func TestExternalParseFormatPresets(t *testing.T) { t.Parallel() for _, name := range []string{ format.BareFormatName, format.PrettyFormatName, format.JSONFormatName, format.KeyValueFormatName, } { t.Run(name, func(t *testing.T) { t.Parallel() phs, err := format.ParseFormat(name) require.NoError(t, err) assert.NotEmpty(t, phs) }) } } // TestExternalCustomFormatParsing parses a user-supplied format string through // the placeholders subpackage and formats an entry with it. func TestExternalCustomFormatParsing(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) fmtr.SetDisabledColors(true) require.NoError(t, fmtr.SetCustomFormat("%level %msg")) buf := new(bytes.Buffer) logger := log.New( log.WithLevel(log.InfoLevel), log.WithOutput(buf), log.WithFormatter(fmtr), ) logger.Info("custom format test") output := buf.String() assert.Contains(t, output, "info") assert.Contains(t, output, "custom format test") } // TestExternalPlaceholderRegistry ensures the placeholder register and its // Parse function are accessible to external callers. func TestExternalPlaceholderRegistry(t *testing.T) { t.Parallel() reg := placeholders.NewPlaceholderRegister() assert.NotNil(t, reg.Get("level")) assert.NotNil(t, reg.Get("msg")) assert.NotNil(t, reg.Get("time")) assert.NotNil(t, reg.Get("interval")) assert.Nil(t, reg.Get("nonexistent")) phs, err := placeholders.Parse("%level %msg") require.NoError(t, err) assert.NotEmpty(t, phs) } // TestExternalWriterAdapter verifies that the writer subpackage can be used to // bridge an io.Writer into the logging system. func TestExternalWriterAdapter(t *testing.T) { t.Parallel() buf := new(bytes.Buffer) logger := log.New(log.WithLevel(log.InfoLevel), log.WithOutput(buf)) w := writer.New( writer.WithLogger(logger), writer.WithDefaultLevel(log.InfoLevel), writer.WithMsgSeparator("\n"), ) n, err := w.Write([]byte("line1\nline2")) require.NoError(t, err) assert.Len(t, "line1\nline2", n) assert.Contains(t, buf.String(), "line1") assert.Contains(t, buf.String(), "line2") } // TestExternalWriterParseFunc confirms that a custom parse function can be // supplied to the writer adapter. func TestExternalWriterParseFunc(t *testing.T) { t.Parallel() buf := new(bytes.Buffer) logger := log.New(log.WithLevel(log.TraceLevel), log.WithOutput(buf)) warnLevel := log.WarnLevel w := writer.New( writer.WithLogger(logger), writer.WithParseFunc(func(str string) (string, *time.Time, *log.Level, error) { return "wrapped: " + str, nil, &warnLevel, nil }), ) _, err := w.Write([]byte("hello")) require.NoError(t, err) assert.Contains(t, buf.String(), "wrapped: hello") } // TestExternalANSIUtilities exercises the ANSI helper functions that external // consumers might use when processing coloured log output. func TestExternalANSIUtilities(t *testing.T) { t.Parallel() coloured := "\033[31mred text\033[0m" assert.Equal(t, "red text", log.RemoveAllASCISeq(coloured)) partial := "\033[32mgreen" assert.Contains(t, log.ResetASCISeq(partial), "\033[0m") assert.Equal(t, "plain", log.RemoveAllASCISeq("plain")) assert.Equal(t, "plain", log.ResetASCISeq("plain")) } // TestExternalPackageLevelFunctions confirms the package-level convenience // functions work without creating an explicit logger. func TestExternalPackageLevelFunctions(t *testing.T) { t.Parallel() logger := log.Default() require.NotNil(t, logger) // WithOptions returns a new logger without mutating the default. custom := log.WithOptions(log.WithLevel(log.TraceLevel)) assert.Equal(t, log.TraceLevel, custom.Level()) // WithField / WithFields / WithError return enriched loggers. assert.NotNil(t, log.WithField("k", "v")) assert.NotNil(t, log.WithFields(log.Fields{"a": 1})) assert.NotNil(t, log.WithError(assert.AnError)) } // TestExternalForceLogLevelHook verifies that the force-level hook can be // created and wired in via WithHooks. func TestExternalForceLogLevelHook(t *testing.T) { t.Parallel() hook := log.NewForceLogLevelHook(log.WarnLevel) assert.Len(t, hook.Levels(), 7) buf := new(bytes.Buffer) // The hook can be attached through the public WithHooks option. logger := log.New( log.WithLevel(log.TraceLevel), log.WithOutput(buf), log.WithHooks(hook), ) logger.Info("hooked message") // The message should still appear (hook changes level, dropper formatter // controls visibility based on logger level which is Trace — most permissive). assert.Contains(t, buf.String(), "hooked message") } // TestExternalConstants ensures exported constants from the package are // accessible. func TestExternalConstants(t *testing.T) { t.Parallel() assert.Equal(t, ".", log.CurDir) assert.NotEmpty(t, log.CurDirWithSeparator) assert.Equal(t, "bare", format.BareFormatName) assert.Equal(t, "pretty", format.PrettyFormatName) assert.Equal(t, "json", format.JSONFormatName) assert.Equal(t, "key-value", format.KeyValueFormatName) assert.Equal(t, "prefix", placeholders.WorkDirKeyName) assert.Equal(t, "tf-path", placeholders.TFPathKeyName) assert.Equal(t, "tf-command-args", placeholders.TFCmdArgsKeyName) assert.Equal(t, "tf-command", placeholders.TFCmdKeyName) } ================================================ FILE: pkg/log/fields.go ================================================ package log // Fields is the type used to pass arguments to `WithFields`. type Fields map[string]any ================================================ FILE: pkg/log/force_level_hook.go ================================================ package log import "github.com/sirupsen/logrus" // ForceLogLevelHook is a log hook which can change log level for messages which contains specific substrings type ForceLogLevelHook struct { triggerLevels []logrus.Level forcedLevel logrus.Level } // NewForceLogLevelHook creates default log reduction hook func NewForceLogLevelHook(forcedLevel Level) *ForceLogLevelHook { return &ForceLogLevelHook{ forcedLevel: forcedLevel.ToLogrusLevel(), triggerLevels: AllLevels.ToLogrusLevels(), } } // Levels implements logrus.Hook.Levels() func (hook *ForceLogLevelHook) Levels() []logrus.Level { return hook.triggerLevels } // Fire implements logrus.Hook.Fire() func (hook *ForceLogLevelHook) Fire(entry *logrus.Entry) error { entry.Level = hook.forcedLevel // special formatter to skip printing of log entries since after hook evaluation, entries are printed directly formatter := LogEntriesDropperFormatter{originalFormatter: entry.Logger.Formatter} entry.Logger.Formatter = &formatter return nil } // LogEntriesDropperFormatter is a custom formatter which will ignore log entries which has lower level than preconfigured in logger type LogEntriesDropperFormatter struct { originalFormatter logrus.Formatter } // Format implements logrus.Formatter func (formatter *LogEntriesDropperFormatter) Format(entry *logrus.Entry) ([]byte, error) { if entry.Logger.Level >= entry.Level { return formatter.originalFormatter.Format(entry) } return []byte(""), nil } ================================================ FILE: pkg/log/force_level_hook_test.go ================================================ package log_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestForceLogLevelHookLevels(t *testing.T) { t.Parallel() hook := log.NewForceLogLevelHook(log.WarnLevel) levels := hook.Levels() assert.Len(t, levels, 7) } func TestForceLogLevelHookFire(t *testing.T) { t.Parallel() hook := log.NewForceLogLevelHook(log.WarnLevel) logger := logrus.New() logger.SetLevel(logrus.TraceLevel) entry := logrus.NewEntry(logger) entry.Level = logrus.InfoLevel err := hook.Fire(entry) require.NoError(t, err) // Entry level should be changed to the forced level (WarnLevel = 3, + shift 2 = logrus.Level(5)) assert.Equal(t, log.WarnLevel.ToLogrusLevel(), entry.Level) } func TestLogEntriesDropperFormatter(t *testing.T) { t.Parallel() tests := []struct { name string loggerLevel log.Level forcedLevel log.Level expectEmpty bool }{ { name: "entry_at_logger_level_produces_output", loggerLevel: log.InfoLevel, forcedLevel: log.InfoLevel, expectEmpty: false, }, { name: "entry_above_logger_level_produces_output", loggerLevel: log.TraceLevel, forcedLevel: log.InfoLevel, expectEmpty: false, }, { name: "entry_below_logger_level_produces_empty", loggerLevel: log.ErrorLevel, forcedLevel: log.InfoLevel, expectEmpty: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() logger := logrus.New() logger.SetLevel(tc.loggerLevel.ToLogrusLevel()) hook := log.NewForceLogLevelHook(tc.forcedLevel) entry := logrus.NewEntry(logger) entry.Level = logrus.InfoLevel entry.Message = "test message" err := hook.Fire(entry) require.NoError(t, err) // After Fire, the logger's formatter is a LogEntriesDropperFormatter. // The dropper checks entry.Logger.Level >= entry.Level. output, err := logger.Formatter.Format(entry) require.NoError(t, err) if tc.expectEmpty { assert.Empty(t, string(output)) } else { assert.NotEmpty(t, string(output)) } }) } } ================================================ FILE: pkg/log/format/format.go ================================================ // Package format implements a custom format logs package format import ( "fmt" "maps" "slices" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" . "github.com/gruntwork-io/terragrunt/pkg/log/format/options" //nolint:revive . "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" //nolint:revive ) const ( BareFormatName = "bare" PrettyFormatName = "pretty" JSONFormatName = "json" KeyValueFormatName = "key-value" ) func NewBareFormatPlaceholders() Placeholders { return Placeholders{ Level( Width(4), //nolint:mnd Case(UpperCase), ), Interval( Prefix("["), Suffix("]"), ), PlainText(" "), Message(), Field(WorkDirKeyName, PathFormat(ShortPath), Prefix("\t prefix=["), Suffix("] "), ), } } func NewPrettyFormatPlaceholders() Placeholders { return Placeholders{ Time( TimeFormat(fmt.Sprintf("%s:%s:%s%s", Hour24Zero, MinZero, SecZero, MilliSec)), Color(LightBlackColor), ), PlainText(" "), Level( Width(6), //nolint:mnd Case(UpperCase), Color(PresetColor), ), PlainText(" "), Field(WorkDirKeyName, PathFormat(ShortRelativePath), Prefix("["), Suffix("] "), Color(GradientColor), ), Field(TFPathKeyName, PathFormat(FilenamePath), Suffix(": "), Color(CyanColor), ), Message(), Field(CacheServerURLKeyName, Prefix(" "+CacheServerURLKeyName+"="), ), Field(CacheServerStatusKeyName, Prefix(" "+CacheServerStatusKeyName+"="), ), } } func NewJSONFormatPlaceholders() Placeholders { return Placeholders{ PlainText(`{`), Time( Prefix(`"time":"`), Suffix(`"`), TimeFormat(RFC3339), Escape(JSONEscape), ), Level( Prefix(`, "level":"`), Suffix(`"`), Escape(JSONEscape), ), Field(WorkDirKeyName, Prefix(`, "working-dir":"`), Suffix(`"`), Escape(JSONEscape), ), Field(TFPathKeyName, Prefix(`, "tf-path":"`), Suffix(`"`), PathFormat(FilenamePath), Escape(JSONEscape), ), Field(TFCmdArgsKeyName, Prefix(`, "tf-command-args":[`), Suffix(`]`), Escape(JSONEscape), ), Message( Prefix(`, "msg":"`), Suffix(`"`), Color(DisableColor), Escape(JSONEscape), ), PlainText(`}`), } } func NewKeyValueFormatPlaceholders() Placeholders { return Placeholders{ Time( Prefix("time="), TimeFormat(RFC3339), ), Level( Prefix(" level="), ), Field(WorkDirKeyName, Prefix(" prefix="), PathFormat(ShortRelativePath), ), Field(TFPathKeyName, Prefix(" tf-path="), PathFormat(FilenamePath), ), Message( Prefix(" msg="), Color(DisableColor), ), } } func ParseFormat(str string) (Placeholders, error) { var presets = map[string]func() Placeholders{ BareFormatName: NewBareFormatPlaceholders, PrettyFormatName: NewPrettyFormatPlaceholders, JSONFormatName: NewJSONFormatPlaceholders, KeyValueFormatName: NewKeyValueFormatPlaceholders, } for name, formatFn := range presets { if name == str { return formatFn(), nil } } return nil, errors.Errorf("available values: %s", strings.Join(slices.Collect(maps.Keys(presets)), ",")) } ================================================ FILE: pkg/log/format/format_test.go ================================================ package format_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseFormat(t *testing.T) { t.Parallel() tests := []struct { name string input string expectErr bool }{ {name: "bare", input: "bare"}, {name: "pretty", input: "pretty"}, {name: "json", input: "json"}, {name: "key-value", input: "key-value"}, {name: "nonexistent", input: "nonexistent", expectErr: true}, {name: "empty", input: "", expectErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() phs, err := format.ParseFormat(tc.input) if tc.expectErr { require.Error(t, err) assert.Nil(t, phs) } else { require.NoError(t, err) assert.NotNil(t, phs) assert.NotEmpty(t, phs) } }) } } func TestFormatterFormat(t *testing.T) { t.Parallel() phs := format.NewBareFormatPlaceholders() fmtr := format.NewFormatter(phs) fmtr.SetDisabledColors(true) logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "hello formatter" entry := &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, } output, err := fmtr.Format(entry) require.NoError(t, err) assert.NotEmpty(t, output) assert.Contains(t, string(output), "hello formatter") } func TestFormatterDisabledOutput(t *testing.T) { t.Parallel() phs := format.NewBareFormatPlaceholders() fmtr := format.NewFormatter(phs) fmtr.SetDisabledOutput(true) logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "should not appear" entry := &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, } output, err := fmtr.Format(entry) require.NoError(t, err) assert.Nil(t, output) } func TestFormatterSetFormat(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) err := fmtr.SetFormat("bare") require.NoError(t, err) logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "bare format" entry := &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, } output, err := fmtr.Format(entry) require.NoError(t, err) assert.Contains(t, string(output), "bare format") } func TestFormatterSetCustomFormat(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) fmtr.SetDisabledColors(true) err := fmtr.SetCustomFormat("%level %msg") require.NoError(t, err) logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "custom msg" entry := &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, Fields: log.Fields{}, } output, err := fmtr.Format(entry) require.NoError(t, err) outputStr := string(output) assert.Contains(t, outputStr, "info") assert.Contains(t, outputStr, "custom msg") } func TestFormatterNilPlaceholders(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "nil placeholders" entry := &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, } output, err := fmtr.Format(entry) require.NoError(t, err) assert.Nil(t, output) } func TestPlaceholderFormatsAccessible(t *testing.T) { t.Parallel() t.Run("bare_format", func(t *testing.T) { t.Parallel() phs := format.NewBareFormatPlaceholders() assert.NotEmpty(t, phs) // Format should work with minimal data logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = log.InfoLevel.ToLogrusLevel() logrusEntry.Message = "test" data := &options.Data{ Entry: &log.Entry{ Entry: logrusEntry, Level: log.InfoLevel, }, } result, err := phs.Format(data) require.NoError(t, err) assert.NotEmpty(t, result) }) t.Run("pretty_format", func(t *testing.T) { t.Parallel() phs := format.NewPrettyFormatPlaceholders() assert.NotEmpty(t, phs) }) t.Run("json_format", func(t *testing.T) { t.Parallel() phs := format.NewJSONFormatPlaceholders() assert.NotEmpty(t, phs) }) t.Run("key_value_format", func(t *testing.T) { t.Parallel() phs := format.NewKeyValueFormatPlaceholders() assert.NotEmpty(t, phs) }) } func TestFormatterSetFormatInvalid(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) err := fmtr.SetFormat("nonexistent") assert.Error(t, err) } func TestFormatterSetCustomFormatInvalid(t *testing.T) { t.Parallel() fmtr := format.NewFormatter(nil) err := fmtr.SetCustomFormat("%banana") assert.Error(t, err) } func TestFormatterPlaceholderRegisterNames(t *testing.T) { t.Parallel() phs := placeholders.NewPlaceholderRegister() names := phs.Names() assert.NotEmpty(t, names) assert.Contains(t, names, "level") assert.Contains(t, names, "msg") assert.Contains(t, names, "time") assert.Contains(t, names, "interval") } ================================================ FILE: pkg/log/format/formatter.go ================================================ package format import ( "bytes" "sync" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" ) // Formatter implements logrus.Formatter. var _ log.Formatter = new(Formatter) type Formatter struct { relativePather *options.RelativePather baseDir string placeholders placeholders.Placeholders mu sync.Mutex disabledColors bool disabledOutput bool } // NewFormatter returns a new Formatter instance with default values. func NewFormatter(phs placeholders.Placeholders) *Formatter { return &Formatter{ placeholders: phs, } } // Format implements logrus.Format. func (formatter *Formatter) Format(entry *log.Entry) ([]byte, error) { if formatter.placeholders == nil || formatter.disabledOutput { return nil, nil } buf := entry.Buffer if buf == nil { buf = new(bytes.Buffer) } str, err := formatter.placeholders.Format(&options.Data{ Entry: entry, BaseDir: formatter.baseDir, DisabledColors: formatter.disabledColors, RelativePather: formatter.relativePather, }) if err != nil { return nil, err } formatter.mu.Lock() defer formatter.mu.Unlock() if str != "" { if _, err := buf.WriteString(str); err != nil { return nil, errors.New(err) } if err := buf.WriteByte('\n'); err != nil { return nil, errors.New(err) } } return buf.Bytes(), nil } // SetBaseDir creates a set of relative paths that are used to convert full paths to relative ones. func (formatter *Formatter) SetBaseDir(baseDir string) error { pather, err := options.NewRelativePather(baseDir) if err != nil { return err } formatter.relativePather = pather formatter.baseDir = baseDir return nil } // DisableRelativePaths disables the conversion of absolute paths to relative ones. func (formatter *Formatter) DisableRelativePaths() { formatter.relativePather = nil } // SetFormat parses and sets log format. func (formatter *Formatter) SetFormat(str string) error { phs, err := ParseFormat(str) if err != nil { return err } formatter.placeholders = phs return nil } // SetCustomFormat parses and sets custom log format. func (formatter *Formatter) SetCustomFormat(str string) error { phs, err := placeholders.Parse(str) if err != nil { return err } formatter.placeholders = phs return nil } // SetDisabledColors enables/disables log colors. func (formatter *Formatter) SetDisabledColors(val bool) { formatter.disabledColors = val } // DisabledColors returns true if log colors are disabled. func (formatter *Formatter) DisabledColors() bool { return formatter.disabledColors } // SetDisabledOutput enables/disables log output. func (formatter *Formatter) SetDisabledOutput(val bool) { formatter.disabledOutput = val } // DisabledOutput returns true if log output is disabled. func (formatter *Formatter) DisabledOutput() bool { return formatter.disabledOutput } ================================================ FILE: pkg/log/format/options/align.go ================================================ package options import ( "strings" ) // AlignOptionName is the option name. const AlignOptionName = "align" const ( NoneAlign AlignValue = iota LeftAlign CenterAlign RightAlign ) var alignList = NewMapValue(map[AlignValue]string{ //nolint:gochecknoglobals LeftAlign: "left", CenterAlign: "center", RightAlign: "right", }) type AlignValue byte type AlignOption struct { *CommonOption[AlignValue] } // Format implements `Option` interface. func (option *AlignOption) Format(_ *Data, val any) (any, error) { str := toString(val) withoutSpaces := strings.TrimSpace(str) spaces := len(str) - len(withoutSpaces) switch option.value.Get() { case LeftAlign: return withoutSpaces + strings.Repeat(" ", spaces), nil case RightAlign: return strings.Repeat(" ", spaces) + withoutSpaces, nil case CenterAlign: twoSides := 2 rightSpaces := (spaces - spaces%2) / twoSides leftSpaces := spaces - rightSpaces return strings.Repeat(" ", leftSpaces) + strings.TrimSpace(str) + strings.Repeat(" ", rightSpaces), nil case NoneAlign: } return str, nil } // Align creates the option to align text relative to the edges. func Align(value AlignValue) Option { return &AlignOption{ CommonOption: NewCommonOption(AlignOptionName, alignList.Set(value)), } } ================================================ FILE: pkg/log/format/options/case.go ================================================ package options import ( "strings" "golang.org/x/text/cases" "golang.org/x/text/language" ) // CaseOptionName is the option name. const CaseOptionName = "case" const ( NoneCase CaseValue = iota UpperCase LowerCase CapitalizeCase ) var caseList = NewMapValue(map[CaseValue]string{ //nolint:gochecknoglobals UpperCase: "upper", LowerCase: "lower", CapitalizeCase: "capitalize", }) type CaseValue byte type CaseOption struct { *CommonOption[CaseValue] } // Format implements `Option` interface. func (option *CaseOption) Format(_ *Data, val any) (any, error) { str := toString(val) switch option.value.Get() { case UpperCase: return strings.ToUpper(str), nil case LowerCase: return strings.ToLower(str), nil case CapitalizeCase: return cases.Title(language.English, cases.Compact).String(str), nil case NoneCase: } return str, nil } // Case creates the option to change the case of text. func Case(value CaseValue) Option { return &CaseOption{ CommonOption: NewCommonOption(CaseOptionName, caseList.Set(value)), } } ================================================ FILE: pkg/log/format/options/color.go ================================================ package options import ( "maps" "slices" "strconv" "strings" "sync" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mgutz/ansi" "github.com/puzpuzpuz/xsync/v3" ) // ColorOptionName is the option name. const ColorOptionName = "color" const ( NoneColor ColorValue = iota + 255 DisableColor GradientColor PresetColor BlackColor RedColor WhiteColor YellowColor GreenColor BlueColor CyanColor MagentaColor LightBlueColor LightBlackColor LightRedColor LightGreenColor LightYellowColor LightMagentaColor LightCyanColor LightWhiteColor ) var ( colorList = NewColorList(map[ColorValue]string{ //nolint:gochecknoglobals PresetColor: "preset", GradientColor: "gradient", DisableColor: "disable", BlackColor: "black", RedColor: "red", WhiteColor: "white", YellowColor: "yellow", GreenColor: "green", CyanColor: "cyan", MagentaColor: "magenta", BlueColor: "blue", LightBlueColor: "light-blue", LightBlackColor: "light-black", LightRedColor: "light-red", LightGreenColor: "light-green", LightYellowColor: "light-yellow", LightMagentaColor: "light-magenta", LightCyanColor: "light-cyan", LightWhiteColor: "light-white", }) colorScheme = ColorScheme{ //nolint:gochecknoglobals BlackColor: "black", RedColor: "red", WhiteColor: "white", YellowColor: "yellow", GreenColor: "green", CyanColor: "cyan", BlueColor: "blue", MagentaColor: "magenta", LightBlueColor: "blue+h", LightBlackColor: "black+h", LightRedColor: "red+h", LightGreenColor: "green+h", LightYellowColor: "yellow+h", LightMagentaColor: "magenta+h", LightCyanColor: "cyan+h", LightWhiteColor: "white+h", } ) type ColorList struct { MapValue[ColorValue] } func NewColorList(list map[ColorValue]string) ColorList { return ColorList{ MapValue: NewMapValue(list), } } func (val *ColorList) Set(v ColorValue) *ColorList { return &ColorList{MapValue: *val.MapValue.Set(v)} } func (val *ColorList) Parse(str string) error { if num, err := strconv.Atoi(str); err == nil && num >= 0 && num <= 255 { val.value = ColorValue(byte(num)) return nil } if err := val.MapValue.Parse(str); err != nil { return errors.Errorf("available values: 0..255,%s", strings.Join(slices.Collect(maps.Values(val.list)), ",")) } return nil } type ColorScheme map[ColorValue]ColorStyle func (scheme ColorScheme) Compile() compiledColorScheme { compiled := make(compiledColorScheme, len(scheme)) for name, val := range scheme { compiled[name] = val.ColorFunc() } for i := range 255 { s := strconv.Itoa(i) compiled[ColorValue(i)] = ColorStyle(s).ColorFunc() } return compiled } type ColorStyle string func (val ColorStyle) ColorFunc() ColorFunc { return ansi.ColorFunc(string(val)) } type ColorFunc func(string) string type ColorValue int type compiledColorScheme map[ColorValue]ColorFunc type ColorOption struct { *CommonOption[ColorValue] compiledColors compiledColorScheme gradientColor *gradientColor } // Format implements `Option` interface. func (color *ColorOption) Format(data *Data, val any) (any, error) { var ( str = toString(val) value = color.value.Get() ) if value == NoneColor { return str, nil } if value == DisableColor || data.DisabledColors { return log.RemoveAllASCISeq(str), nil } if value == PresetColor && data.PresetColorFn != nil { value = data.PresetColorFn() } if value == GradientColor && color.gradientColor != nil { value = color.gradientColor.Value(str) } if colorFn, ok := color.compiledColors[value]; ok { str = colorFn(str) } return str, nil } // Color creates the option to change the color of text. func Color(val ColorValue) Option { return &ColorOption{ CommonOption: NewCommonOption(ColorOptionName, colorList.Set(val)), compiledColors: colorScheme.Compile(), gradientColor: newGradientColor(), } } var ( // defaultAutoColorValues contains ANSI color codes that are assigned sequentially to each unique text in a rotating order // https://user-images.githubusercontent.com/995050/47952855-ecb12480-df75-11e8-89d4-ac26c50e80b9.png // https://www.hackitu.de/termcolor256/ defaultAutoColorValues = []ColorValue{ //nolint:gochecknoglobals 66, 67, 95, 96, 102, 103, 108, 109, 138, 139, 144, 145, } ) type gradientColor struct { // cache stores unique text with their color code. // We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map) instead of standard `sync.Map` since it's faster and has generic types. cache *xsync.MapOf[string, ColorValue] values []ColorValue mu sync.Mutex // nextStyleIndex is used to get the next style from the `codes` list for a newly discovered text. nextStyleIndex int } func newGradientColor() *gradientColor { return &gradientColor{ cache: xsync.NewMapOf[string, ColorValue](), values: defaultAutoColorValues, } } func (color *gradientColor) Value(text string) ColorValue { color.mu.Lock() defer color.mu.Unlock() if colorCode, ok := color.cache.Load(text); ok { return colorCode } if color.nextStyleIndex >= len(color.values) { color.nextStyleIndex = 0 } colorCode := color.values[color.nextStyleIndex] color.cache.Store(text, colorCode) color.nextStyleIndex++ return colorCode } ================================================ FILE: pkg/log/format/options/common.go ================================================ package options import ( "fmt" "maps" "slices" "strconv" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" ) type CommonOption[T comparable] struct { value OptionValue[T] name string } // NewCommonOption creates a new Common option. func NewCommonOption[T comparable](name string, value OptionValue[T]) *CommonOption[T] { return &CommonOption[T]{ name: name, value: value, } } // String implements `fmt.Stringer` interface. func (option *CommonOption[T]) String() string { return fmt.Sprintf("%v", option.value.Get()) } // Name implements `Option` interface. func (option *CommonOption[T]) Name() string { return option.name } // Format implements `Option` interface. func (option *CommonOption[T]) Format(_ *Data, str string) (string, error) { return str, nil } // ParseValue implements `Option` interface. func (option *CommonOption[T]) ParseValue(str string) error { return option.value.Parse(str) } type StringValue string func NewStringValue(val string) *StringValue { v := StringValue(val) return &v } func (val *StringValue) Parse(str string) error { *val = StringValue(str) return nil } func (val *StringValue) Get() string { return string(*val) } type IntValue int func NewIntValue(val int) *IntValue { v := IntValue(val) return &v } func (val *IntValue) Parse(str string) error { v, err := strconv.Atoi(str) if err != nil { return errors.Errorf("incorrect option value: %s", str) } *val = IntValue(v) return nil } func (val *IntValue) Get() int { return int(*val) } type MapValue[T comparable] struct { list map[T]string value T } func NewMapValue[T comparable](list map[T]string) MapValue[T] { return MapValue[T]{ list: list, } } func (val *MapValue[T]) Get() T { return val.value } func (val MapValue[T]) Set(v T) *MapValue[T] { val.value = v return &val } func (val *MapValue[T]) Parse(str string) error { for v, name := range val.list { if name == str { val.value = v return nil } } list := slices.Sorted(maps.Values(val.list)) return errors.Errorf("available values: %s", strings.Join(list, ",")) } func (val *MapValue[T]) Filter(vals ...T) MapValue[T] { newVal := MapValue[T]{ list: make(map[T]string, len(vals)), } for _, v := range vals { if name, ok := val.list[v]; ok { newVal.list[v] = name } } return newVal } ================================================ FILE: pkg/log/format/options/content.go ================================================ package options // ContentOptionName is the option name. const ContentOptionName = "content" type ContentOption struct { *CommonOption[string] } // Format implements `Option` interface. func (option *ContentOption) Format(_ *Data, val any) (any, error) { if val := option.value.Get(); val != "" { return val, nil } return val, nil } // Content creates the option that sets the content. func Content(val string) Option { return &ContentOption{ CommonOption: NewCommonOption(ContentOptionName, NewStringValue(val)), } } ================================================ FILE: pkg/log/format/options/errors.go ================================================ package options import ( "fmt" "strings" ) // InvalidOptionError is an invalid `option` syntax error. type InvalidOptionError struct { str string } // NewInvalidOptionError returns a new `InvalidOptionError` instance. func NewInvalidOptionError(str string) *InvalidOptionError { return &InvalidOptionError{ str: str, } } func (err InvalidOptionError) Error() string { return fmt.Sprintf("invalid option syntax %q", err.str) } // EmptyOptionNameError is an empty `option` name error. type EmptyOptionNameError struct { str string } // NewEmptyOptionNameError returns a new `EmptyOptionNameError` instance. func NewEmptyOptionNameError(str string) *EmptyOptionNameError { return &EmptyOptionNameError{ str: str, } } func (err EmptyOptionNameError) Error() string { return fmt.Sprintf("empty option name %q", err.str) } // InvalidOptionNameError is an invalid `option` name error. type InvalidOptionNameError struct { name string opts Options } // NewInvalidOptionNameError returns a new `InvalidOptionNameError` instance. func NewInvalidOptionNameError(name string, opts Options) *InvalidOptionNameError { return &InvalidOptionNameError{ name: name, opts: opts, } } func (err InvalidOptionNameError) Error() string { return fmt.Sprintf("invalid option name %q, available names: %s", err.name, strings.Join(err.opts.Names(), ",")) } // InvalidOptionValueError is an invalid `option` value error. type InvalidOptionValueError struct { opt Option err error val string } // NewInvalidOptionValueError returns a new `InvalidOptionValueError` instance. func NewInvalidOptionValueError(opt Option, val string, err error) *InvalidOptionValueError { return &InvalidOptionValueError{ val: val, opt: opt, err: err, } } func (err InvalidOptionValueError) Error() string { return fmt.Sprintf("option %q, invalid value %q, %v", err.opt.Name(), err.val, err.err) } func (err InvalidOptionValueError) Unwrap() error { return err.err } ================================================ FILE: pkg/log/format/options/escape.go ================================================ package options import ( "encoding/json" "github.com/gruntwork-io/terragrunt/internal/errors" ) // EscapeOptionName is the option name. const EscapeOptionName = "escape" const ( NoneEscape EscapeValue = iota JSONEscape ) var escapeList = NewMapValue(map[EscapeValue]string{ //nolint:gochecknoglobals JSONEscape: "json", }) type EscapeValue byte type EscapeOption struct { *CommonOption[EscapeValue] } // Format implements `Option` interface. func (option *EscapeOption) Format(_ *Data, val any) (any, error) { if option.value.Get() != JSONEscape { return val, nil } jsonStr, err := json.Marshal(val) if err != nil { return "", errors.New(err) } // Trim the beginning and trailing " character. return string(jsonStr[1 : len(jsonStr)-1]), nil } // Escape creates the option to escape text. func Escape(val EscapeValue) Option { return &EscapeOption{ CommonOption: NewCommonOption(EscapeOptionName, escapeList.Set(val)), } } ================================================ FILE: pkg/log/format/options/level_format.go ================================================ package options // LevelFormatOptionName is the option name. const LevelFormatOptionName = "format" const ( LevelFormatFull LevelFormatValue = iota LevelFormatShort LevelFormatTiny ) var levelFormatList = NewMapValue(map[LevelFormatValue]string{ //nolint:gochecknoglobals LevelFormatTiny: "tiny", LevelFormatShort: "short", LevelFormatFull: "full", }) type LevelFormatValue byte type LevelFormatOption struct { *CommonOption[LevelFormatValue] } // Format implements `Option` interface. func (format *LevelFormatOption) Format(data *Data, _ any) (any, error) { switch format.value.Get() { case LevelFormatTiny: return data.Level.TinyName(), nil case LevelFormatShort: return data.Level.ShortName(), nil case LevelFormatFull: } return data.Level.FullName(), nil } // LevelFormat creates the option to format level name. func LevelFormat(val LevelFormatValue) Option { return &LevelFormatOption{ CommonOption: NewCommonOption(LevelFormatOptionName, levelFormatList.Set(val)), } } ================================================ FILE: pkg/log/format/options/option.go ================================================ // Package options represents a set of placeholders options. package options import ( "reflect" "strings" "unicode" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) // Constants for parsing options. const ( OptNameValueSep = "=" OptSep = "," OptStartSign = "(" OptEndSign = ")" ) const splitIntoNameAndValue = 2 // OptionValue contains the value of the option. type OptionValue[T any] interface { // Configure parses and sets the value of the option. Parse(str string) error // Get returns the value of the option. Get() T } // Option represents a value modifier of placeholders. type Option interface { // Name returns the name of the option. Name() string // Format formats the given string. Format(data *Data, str any) (any, error) // ParseValue parses and sets the value of the option. ParseValue(str string) error } // Data is a log entry data. type Data struct { *log.Entry RelativePather *RelativePather PresetColorFn func() ColorValue BaseDir string DisabledColors bool } // Options is a set of Options. type Options []Option // Get returns the option with the given name. func (opts Options) Get(name string) Option { for _, opt := range opts { if opt.Name() == name { return opt } } return nil } // Names returns names of the options. func (opts Options) Names() []string { var names = make([]string, len(opts)) for i, opt := range opts { names[i] = opt.Name() } return names } // Merge replaces options with the same name and adds new ones to the end. func (opts Options) Merge(withOpts ...Option) Options { for i := range opts { for t := range withOpts { if reflect.TypeOf(opts[i]) == reflect.TypeOf(withOpts[t]) { opts[i] = withOpts[t] withOpts = slices.Delete(withOpts, t, t+1) break } } } return append(opts, withOpts...) } // Format returns the formatted value. func (opts Options) Format(data *Data, str any) (string, error) { var err error for _, opt := range opts { str, err = opt.Format(data, str) if str == "" || err != nil { return "", err } } return toString(str), nil } // Configure parsers the given `str` to configure the `opts` and returns the rest of the given `str`. // // e.g. (color=green, case=upper) some-text" sets `color` option to `green`, `case` option to `upper` and returns " some-text". func (opts Options) Configure(str string) (string, error) { if len(str) == 0 || !strings.HasPrefix(str, OptStartSign) { return str, nil } str = str[1:] for { var ( ok bool err error ) if str, ok = nextOption(str); !ok { return str, nil } parts := strings.SplitN(str, OptNameValueSep, splitIntoNameAndValue) if len(parts) != splitIntoNameAndValue { return "", errors.New(NewInvalidOptionError(str)) } name := strings.TrimSpace(parts[0]) if name == "" { return "", errors.New(NewEmptyOptionNameError(str)) } opt := opts.Get(name) if opt == nil { return "", errors.New(NewInvalidOptionNameError(name, opts)) } if str, err = setOptionValue(opt, parts[1]); err != nil { return "", err } } } // setOptionValue parses the given `str` and sets the value for the given `opt` and returns the rest of the given `str`. // // e.g. "green, case=upper) some-text" sets "green" to the option and returns ", case=upper) some-text". // e.g. "' quoted value ') some-text" sets " quoted value " to the option and returns ") some-text". func setOptionValue(opt Option, str string) (string, error) { var quoteChar byte for index := range str { if quoteOpened(str[:index], "eChar) { continue } lastSign := str[index : index+1] if !strings.HasSuffix(lastSign, OptSep) && !strings.HasSuffix(lastSign, OptEndSign) { continue } val := strings.TrimSpace(str[:index]) val = strings.Trim(val, "'") val = strings.Trim(val, "\"") if err := opt.ParseValue(val); err != nil { return "", errors.New(NewInvalidOptionValueError(opt, val, err)) } return str[index:], nil } return str, nil } // nextOption returns true if the given `str` contains one more option // and returns the given `str` without separator sign "," or ")". // // e.g. ",color=green) some-text" returns "color=green) some-text" and `true`. // e.g. "(color=green) some-text" returns "color=green) some-text" and `true`. // e.g. ") some-text" returns " some-text" and `false`. func nextOption(str string) (string, bool) { str = strings.TrimLeftFunc(str, unicode.IsSpace) switch { case strings.HasPrefix(str, OptEndSign): return str[1:], false case strings.HasPrefix(str, OptSep): return str[1:], true } return str, true } // quoteOpened returns true if the given `str` contains an unclosed quote. // // e.g. "%(content=' level" return `true`. // e.g. "%(content=' level '" return `false`. // e.g. "%(content=\" level" return `true`. func quoteOpened(str string, quoteChar *byte) bool { strlen := len(str) if strlen == 0 { return false } char := str[strlen-1] if char == '"' || char == '\'' { if *quoteChar == 0 { *quoteChar = char } else if *quoteChar == char && (strlen < 2 || str[strlen-2] != '\\') { *quoteChar = 0 } } return *quoteChar != 0 } ================================================ FILE: pkg/log/format/options/path_format.go ================================================ package options import ( "fmt" "os" "path/filepath" "regexp" "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) // PathFormatOptionName is the option name. const PathFormatOptionName = "path" const ( NonePath PathFormatValue = iota RelativePath ShortRelativePath ShortPath FilenamePath DirectoryPath ) var pathFormatList = NewMapValue(map[PathFormatValue]string{ //nolint:gochecknoglobals RelativePath: "relative", ShortRelativePath: "short-relative", ShortPath: "short", FilenamePath: "filename", DirectoryPath: "dir", }) type PathFormatValue byte type PathFormatOption struct { *CommonOption[PathFormatValue] } // Format implements `Option` interface. func (option *PathFormatOption) Format(data *Data, val any) (any, error) { str := toString(val) switch option.value.Get() { case RelativePath: if data.RelativePather == nil { break } return data.RelativePather.ReplaceAbsPaths(str), nil case ShortRelativePath: if data.RelativePather == nil { break } return option.shortRelativePath(data, str), nil case ShortPath: if str == data.BaseDir { return "", nil } return str, nil case FilenamePath: return filepath.Base(str), nil case DirectoryPath: return filepath.Dir(str), nil case NonePath: } return val, nil } func (option *PathFormatOption) shortRelativePath(data *Data, str string) string { if str == data.BaseDir { return "" } str = data.RelativePather.ReplaceAbsPaths(str) if strings.HasPrefix(str, log.CurDirWithSeparator) { return str[len(log.CurDirWithSeparator):] } return str } // PathFormat creates the option to format the paths. func PathFormat(val PathFormatValue, allowed ...PathFormatValue) Option { list := pathFormatList if len(allowed) > 0 { list = list.Filter(allowed...) } return &PathFormatOption{ CommonOption: NewCommonOption(PathFormatOptionName, list.Set(val)), } } // RelativePather replaces absolute paths with relative ones, // For better performance, during instance creation, we creating a cache of relative paths for each subdirectory of baseDir. // // Example of cache: // /path/to/dir ./ // /path/to ../ // /path ../.. type RelativePather struct { relPaths []string absPathsReg []*regexp.Regexp } // NewRelativePather returns a new RelativePather instance. // It returns an error if the cache of relative paths could not be created for the given `baseDir`. func NewRelativePather(baseDir string) (*RelativePather, error) { baseDir = filepath.Clean(baseDir) pathSeparator := string(os.PathSeparator) dirs := strings.Split(baseDir, pathSeparator) absPath := dirs[0] dirs = dirs[1:] relPaths := make([]string, len(dirs)) absPathsReg := make([]*regexp.Regexp, len(dirs)) reversIndex := len(dirs) for _, dir := range dirs { absPath = filepath.Join(absPath, pathSeparator, dir) relPath, err := filepath.Rel(baseDir, absPath) if err != nil { return nil, errors.New(err) } reversIndex-- relPaths[reversIndex] = relPath regStr := fmt.Sprintf(`(^|[^%[1]s\w])%[2]s([%[1]s"'\s]|$)`, regexp.QuoteMeta(pathSeparator), regexp.QuoteMeta(absPath)) absPathsReg[reversIndex] = regexp.MustCompile(regStr) } return &RelativePather{ absPathsReg: absPathsReg, relPaths: relPaths, }, nil } func (hook *RelativePather) ReplaceAbsPaths(str string) string { for i, absPath := range hook.absPathsReg { str = absPath.ReplaceAllString(str, "$1"+hook.relPaths[i]+"$2") } return str } ================================================ FILE: pkg/log/format/options/prefix.go ================================================ package options // PrefixOptionName is the option name. const PrefixOptionName = "prefix" type PrefixOption struct { *CommonOption[string] } // Format implements `Option` interface. func (option *PrefixOption) Format(_ *Data, val any) (any, error) { return option.value.Get() + toString(val), nil } // Prefix creates the option to add a prefix to the text. func Prefix(val string) Option { return &PrefixOption{ CommonOption: NewCommonOption(PrefixOptionName, NewStringValue(val)), } } ================================================ FILE: pkg/log/format/options/suffix.go ================================================ package options // SuffixOptionName is the option name. const SuffixOptionName = "suffix" type SuffixOption struct { *CommonOption[string] } // Format implements `Option` interface. func (option *SuffixOption) Format(_ *Data, val any) (any, error) { return toString(val) + option.value.Get(), nil } // Suffix creates the option to add a suffix to the text. func Suffix(val string) Option { return &SuffixOption{ CommonOption: NewCommonOption(SuffixOptionName, NewStringValue(val)), } } ================================================ FILE: pkg/log/format/options/time_format.go ================================================ package options import ( "maps" "slices" "strings" "time" ) // TimeFormatOptionName is the option name. const TimeFormatOptionName = "format" const ( DateTime = "date-time" DateOnly = "date-only" TimeOnly = "time-only" RFC3339 = "rfc3339" RFC3339Nano = "rfc3339-nano" Hour24Zero = "H" Hour12Zero = "h" Hour12 = "g" MinZero = "i" SecZero = "s" MilliSec = "v" MicroSec = "u" YearFull = "Y" Year = "y" MonthNumZero = "m" MonthNum = "n" MonthText = "M" DayZero = "d" Day = "j" DayText = "D" PMUpper = "A" PMLower = "a" TZText = "T" TZNumWithColon = "P" TZNum = "O" ) var ( timeFormatList = NewTimeFormatValue(map[string]string{ //nolint:gochecknoglobals YearFull: "2006", Year: "06", MonthNumZero: "01", MonthNum: "1", MonthText: "Jan", Day: "2", DayZero: "02", DayText: "Mon", PMUpper: "PM", PMLower: "pm", Hour24Zero: "15", Hour12Zero: "03", Hour12: "3", MinZero: "04", SecZero: "05", MicroSec: ".000000", MilliSec: ".000", TZText: "MST", TZNum: "-0700", TZNumWithColon: "-07:00", RFC3339: time.RFC3339, RFC3339Nano: time.RFC3339Nano, DateTime: time.DateTime, DateOnly: time.DateOnly, TimeOnly: time.TimeOnly, }) ) type TimeFormatValue struct { MapValue[string] } func NewTimeFormatValue(list map[string]string) *TimeFormatValue { return &TimeFormatValue{ MapValue: NewMapValue(list), } } func (val TimeFormatValue) SortedKeys() []string { keys := maps.Keys(val.list) return slices.SortedFunc(keys, func(a, b string) int { return strings.Compare(val.list[a], val.list[b]) }) } func (val TimeFormatValue) Set(v string) *TimeFormatValue { val.value = timeFormatList.Value(v) return &val } func (val TimeFormatValue) Value(str string) string { for _, key := range val.SortedKeys() { str = strings.ReplaceAll(str, key, val.list[key]) } return str } func (val *TimeFormatValue) Parse(str string) error { val.value = timeFormatList.Value(str) return nil } type TimeFormatOption struct { *CommonOption[string] } // Format implements `Option` interface. func (option *TimeFormatOption) Format(data *Data, _ any) (any, error) { return data.Time.Format(option.value.Get()), nil } func TimeFormat(val string) Option { return &TimeFormatOption{ CommonOption: NewCommonOption(TimeFormatOptionName, timeFormatList.Set(val)), } } ================================================ FILE: pkg/log/format/options/util.go ================================================ package options import ( "fmt" "strings" ) func toString(val any) string { switch val := val.(type) { case string: return val case []string: return strings.Join(val, " ") } return fmt.Sprintf("%v", val) } ================================================ FILE: pkg/log/format/options/width.go ================================================ package options import ( "strings" "github.com/gruntwork-io/terragrunt/pkg/log" ) // WidthOptionName is the option name. const WidthOptionName = "width" type WidthOption struct { *CommonOption[int] } // Format implements `Option` interface. func (option *WidthOption) Format(_ *Data, val any) (any, error) { str := toString(val) width := option.value.Get() if width == 0 { return str, nil } strLen := len(log.RemoveAllASCISeq(str)) if width < strLen { return str[:width], nil } return str + strings.Repeat(" ", width-strLen), nil } // Width creates the option to set the column width. func Width(val int) Option { return &WidthOption{ CommonOption: NewCommonOption(WidthOptionName, NewIntValue(val)), } } ================================================ FILE: pkg/log/format/placeholders/common.go ================================================ package placeholders import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // WithCommonOptions is a set of common options that are used in all placeholders. func WithCommonOptions(opts ...options.Option) options.Options { return options.Options(append(opts, options.Content(""), options.Escape(options.NoneEscape), options.Case(options.NoneCase), options.Width(0), options.Align(options.NoneAlign), options.Prefix(""), options.Suffix(""), options.Color(options.NoneColor), )) } type CommonPlaceholder struct { name string opts options.Options } // NewCommonPlaceholder creates a new Common placeholder. func NewCommonPlaceholder(name string, opts ...options.Option) *CommonPlaceholder { return &CommonPlaceholder{ name: name, opts: opts, } } // Name implements `Placeholder` interface. func (common *CommonPlaceholder) Name() string { return common.name } // Options implements `Placeholder` interface. func (common *CommonPlaceholder) Options() options.Options { return common.opts } // Format implements `Placeholder` interface. func (common *CommonPlaceholder) Format(data *options.Data) (string, error) { return common.opts.Format(data, "") } ================================================ FILE: pkg/log/format/placeholders/errors.go ================================================ package placeholders import ( "fmt" "strings" ) // InvalidPlaceholderNameError is an invalid `placeholder` name error. type InvalidPlaceholderNameError struct { str string opts Placeholders } // NewInvalidPlaceholderNameError returns a new `InvalidPlaceholderNameError` instance. func NewInvalidPlaceholderNameError(str string, opts Placeholders) *InvalidPlaceholderNameError { return &InvalidPlaceholderNameError{ str: str, opts: opts, } } func (err InvalidPlaceholderNameError) Error() string { var name string for index := range len(err.str) { if !isPlaceholderNameCharacter(err.str[index]) { break } name = err.str[:index+1] } return fmt.Sprintf("invalid placeholder name %q, available names: %s", name, strings.Join(err.opts.Names(), ",")) } // InvalidPlaceholderOptionError is an invalid `placeholder` option error. type InvalidPlaceholderOptionError struct { ph Placeholder err error } // NewInvalidPlaceholderOptionError returns a new `InvalidPlaceholderOptionError` instance. func NewInvalidPlaceholderOptionError(ph Placeholder, err error) *InvalidPlaceholderOptionError { return &InvalidPlaceholderOptionError{ ph: ph, err: err, } } func (err InvalidPlaceholderOptionError) Error() string { return fmt.Sprintf("placeholder %q, %v", err.ph.Name(), err.err) } func (err InvalidPlaceholderOptionError) Unwrap() error { return err.err } ================================================ FILE: pkg/log/format/placeholders/field.go ================================================ package placeholders import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) const ( WorkDirKeyName = "prefix" TFPathKeyName = "tf-path" TFCmdArgsKeyName = "tf-command-args" TFCmdKeyName = "tf-command" // Terragrunt Provider Cache Server fields. CacheServerURLKeyName = "url" CacheServerStatusKeyName = "status" ) type fieldPlaceholder struct { *CommonPlaceholder } // Format implements `Placeholder` interface. func (field *fieldPlaceholder) Format(data *options.Data) (string, error) { if val, ok := data.Fields[field.Name()]; ok { return field.opts.Format(data, val) } return "", nil } // Field creates a placeholder that displays log field value. func Field(fieldName string, opts ...options.Option) Placeholder { opts = WithCommonOptions( options.PathFormat(options.NonePath), ).Merge(opts...) return &fieldPlaceholder{ CommonPlaceholder: NewCommonPlaceholder(fieldName, opts...), } } ================================================ FILE: pkg/log/format/placeholders/interval.go ================================================ package placeholders import ( "fmt" "time" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // IntervalPlaceholderName is the placeholder name. const IntervalPlaceholderName = "interval" type intervalPlaceholder struct { baseTime time.Time *CommonPlaceholder } // Format implements `Placeholder` interface. func (t *intervalPlaceholder) Format(data *options.Data) (string, error) { return t.opts.Format(data, fmt.Sprintf("%04d", time.Since(t.baseTime)/time.Second)) } // Interval creates a placeholder that displays seconds that have passed since app started. func Interval(opts ...options.Option) Placeholder { opts = WithCommonOptions().Merge(opts...) return &intervalPlaceholder{ baseTime: time.Now(), CommonPlaceholder: NewCommonPlaceholder(IntervalPlaceholderName, opts...), } } ================================================ FILE: pkg/log/format/placeholders/level.go ================================================ package placeholders import ( "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // LevelPlaceholderName is the placeholder name. const LevelPlaceholderName = "level" var levlAutoColorFunc = func(level log.Level) options.ColorValue { switch level { case log.TraceLevel: return options.WhiteColor case log.DebugLevel: return options.LightBlueColor case log.InfoLevel: return options.GreenColor case log.WarnLevel: return options.YellowColor case log.ErrorLevel: return options.RedColor case log.StdoutLevel: return options.WhiteColor case log.StderrLevel: return options.RedColor default: return options.NoneColor } } type level struct { *CommonPlaceholder } // Format implements `Placeholder` interface. func (level *level) Format(data *options.Data) (string, error) { newData := *data newData.PresetColorFn = func() options.ColorValue { return levlAutoColorFunc(data.Level) } return level.opts.Format(&newData, data.Level.String()) } // Level creates a placeholder that displays log level name. func Level(opts ...options.Option) Placeholder { opts = WithCommonOptions( options.LevelFormat(options.LevelFormatFull), ).Merge(opts...) return &level{ CommonPlaceholder: NewCommonPlaceholder(LevelPlaceholderName, opts...), } } ================================================ FILE: pkg/log/format/placeholders/message.go ================================================ package placeholders import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // MessagePlaceholderName is the placeholder name. const MessagePlaceholderName = "msg" type message struct { *CommonPlaceholder } // Format implements `Placeholder` interface. func (msg *message) Format(data *options.Data) (string, error) { return msg.opts.Format(data, data.Message) } // Message creates a placeholder that displays log message. func Message(opts ...options.Option) Placeholder { opts = WithCommonOptions( options.PathFormat(options.NonePath, options.RelativePath), ).Merge(opts...) return &message{ CommonPlaceholder: NewCommonPlaceholder(MessagePlaceholderName, opts...), } } ================================================ FILE: pkg/log/format/placeholders/placeholder.go ================================================ // Package placeholders represents a set of placeholders for formatting various log values. package placeholders import ( "strings" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) const ( placeholderSign = "%" splitIntoTextAndPlaceholder = 2 ) // Placeholder is part of the log message, used to format different log values. type Placeholder interface { // Name returns a placeholder name. Name() string // Options returns the placeholder options. Options() options.Options // Format returns the formatted value. Format(data *options.Data) (string, error) } // Placeholders are a set of Placeholders. type Placeholders []Placeholder // NewPlaceholderRegister returns a new `Placeholder` collection instance available for use in a custom format string. func NewPlaceholderRegister() Placeholders { return Placeholders{ Interval(), Time(), Level(), Message(), Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)), Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)), Field(TFCmdArgsKeyName), Field(TFCmdKeyName), } } // Get returns the placeholder by its name. func (phs Placeholders) Get(name string) Placeholder { for _, ph := range phs { if ph.Name() == name { return ph } } return nil } // Names returns the names of the placeholders. func (phs Placeholders) Names() []string { var names = make([]string, len(phs)) for i, ph := range phs { names[i] = ph.Name() } return names } // Format returns a formatted string that is the concatenation of the formatted placeholder values. func (phs Placeholders) Format(data *options.Data) (string, error) { var str string var strSb69 strings.Builder for _, ph := range phs { s, err := ph.Format(data) if err != nil { return "", err } strSb69.WriteString(s) } str += strSb69.String() return str, nil } // findPlaceholder parses the given `str` to find a placeholder name present in the `phs` collection, // returns that placeholder, and the rest of the given `str`. // // e.g. "level(color=green, case=upper) some-text" returns the instance of the `level` placeholder // and "(color=green, case=upper) some-text" string. func (phs Placeholders) findPlaceholder(str string) (Placeholder, string) { //nolint:ireturn var ( placeholder Placeholder optIndex int ) // We don't stop at the first one we find, we look for the longest name. // Of these two `%tf-command` `%tf-command-args` we need to find the second one. for index := range len(str) { if !isPlaceholderNameCharacter(str[index]) { break } name := str[:index+1] if pl := phs.Get(name); pl != nil { placeholder = pl optIndex = index + 1 } } if placeholder != nil { return placeholder, str[optIndex:] } return findPlaintextPlaceholder(str) } // Parse parses the given `str` and returns a set of placeholders that are then used to format log data. func Parse(str string) (Placeholders, error) { var ( placeholders Placeholders placeholder Placeholder err error ) for { // We need to create a new placeholders collection to avoid overriding options // if the custom format string contains two or more same placeholders. // e.g. "%level(format=full) some-text %level(format=tiny)" placeholderRegister := NewPlaceholderRegister() parts := strings.SplitN(str, placeholderSign, splitIntoTextAndPlaceholder) if plaintext := parts[0]; plaintext != "" { placeholders = append(placeholders, PlainText(plaintext)) } if len(parts) == 1 { return placeholders, nil } str = parts[1] placeholder, str = placeholderRegister.findPlaceholder(str) if placeholder == nil { return nil, errors.New(NewInvalidPlaceholderNameError(str, placeholderRegister)) } str, err = placeholder.Options().Configure(str) if err != nil { return nil, errors.New(NewInvalidPlaceholderOptionError(placeholder, err)) } placeholders = append(placeholders, placeholder) } } func findPlaintextPlaceholder(str string) (Placeholder, string) { //nolint:ireturn if len(str) == 0 { return nil, str } switch str[0:1] { case options.OptStartSign: // Unnamed placeholder, format `%(content='...')`. return PlainText(""), str case " ": // Single `%` character, format `% `. return PlainText(placeholderSign), str case placeholderSign: // Escaped `%`, format `%%`. return PlainText(placeholderSign), str[1:] case "t": // Indent, format `%t`. return PlainText("\t"), str[1:] case "n": // Newline, format `%n`. return PlainText("\n"), str[1:] } return nil, str } // isPlaceholderNameCharacter returns true if the given character `c` does not contain any restricted characters for placeholder names. // // e.g. "time" return `true`. // e.g. "time " return `false`. // e.g. "time(" return `false`. func isPlaceholderNameCharacter(c byte) bool { // Check if the byte value falls within the range of alphanumeric characters return c == '-' || c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') } ================================================ FILE: pkg/log/format/placeholders/placeholder_test.go ================================================ package placeholders_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParse(t *testing.T) { t.Parallel() tests := []struct { name string input string expectOutput string // if non-empty, format and check output contains this expectCount int // expected number of placeholders, -1 to skip expectErr bool }{ { name: "single_level", input: "%level", expectCount: 1, }, { name: "single_msg", input: "%msg", expectCount: 1, }, { name: "single_time", input: "%time", expectCount: 1, }, { name: "single_interval", input: "%interval", expectCount: 1, }, { name: "plaintext_only", input: "just plain text", expectCount: 1, expectOutput: "just plain text", }, { name: "mixed_text_and_placeholder", input: "level=%level msg=%msg", expectCount: 4, // "level=" + level + " msg=" + msg }, { name: "with_options", input: "%level(format=short)", expectCount: 1, }, { name: "escaped_percent", input: "100%%", expectCount: 2, // "100" + "%" (from %%) expectOutput: "100%", }, { name: "tab", input: "%t", expectCount: 1, expectOutput: "\t", }, { name: "newline", input: "%n", expectCount: 1, expectOutput: "\n", }, { name: "field_prefix", input: "%prefix", expectCount: 1, }, { name: "field_tf_path", input: "%tf-path", expectCount: 1, }, { name: "field_tf_command_args", input: "%tf-command-args", expectCount: 1, }, { name: "invalid_name_banana", input: "%banana", expectErr: true, }, { name: "complex_multi_placeholder", input: "%level %msg [%interval]", expectCount: 6, // level + " " + msg + " [" + interval + "]" }, { name: "empty_string", input: "", expectCount: 0, expectOutput: "", }, { name: "unnamed_placeholder", input: "%(content='hello')", expectCount: 1, }, { name: "duplicate_placeholders_different_options", input: "%level(format=full) %level(format=short)", expectCount: 3, // level(full) + " " + level(short) }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() phs, err := placeholders.Parse(tc.input) if tc.expectErr { assert.Error(t, err) return } require.NoError(t, err) if tc.expectCount >= 0 { assert.Len(t, phs, tc.expectCount) } if tc.expectOutput != "" { data := newMinimalData("test", log.InfoLevel) output, err := phs.Format(data) require.NoError(t, err) assert.Contains(t, output, tc.expectOutput) } }) } } func TestPlaceholderRegisterNames(t *testing.T) { t.Parallel() phs := placeholders.NewPlaceholderRegister() names := phs.Names() assert.NotEmpty(t, names) expectedNames := []string{"interval", "time", "level", "msg"} for _, name := range expectedNames { assert.Contains(t, names, name) } } func TestPlaceholdersGet(t *testing.T) { t.Parallel() phs := placeholders.NewPlaceholderRegister() t.Run("existing_level", func(t *testing.T) { t.Parallel() ph := phs.Get("level") assert.NotNil(t, ph) assert.Equal(t, "level", ph.Name()) }) t.Run("existing_msg", func(t *testing.T) { t.Parallel() ph := phs.Get("msg") assert.NotNil(t, ph) }) t.Run("nonexistent", func(t *testing.T) { t.Parallel() ph := phs.Get("nonexistent") assert.Nil(t, ph) }) } func TestPlaceholdersFormat(t *testing.T) { t.Parallel() phs := placeholders.Placeholders{ placeholders.PlainText("hello"), placeholders.PlainText(" world"), } data := newMinimalData("", log.InfoLevel) output, err := phs.Format(data) require.NoError(t, err) assert.Equal(t, "hello world", output) } func TestLevelPlaceholderFormats(t *testing.T) { t.Parallel() tests := []struct { name string format string contains string level log.Level }{ {name: "full_info", format: "%level", contains: "info", level: log.InfoLevel}, {name: "full_error", format: "%level", contains: "error", level: log.ErrorLevel}, {name: "short_info", format: "%level(format=short)", contains: "inf", level: log.InfoLevel}, {name: "tiny_info", format: "%level(format=tiny)", contains: "i", level: log.InfoLevel}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() phs, err := placeholders.Parse(tc.format) require.NoError(t, err) data := newMinimalData("msg", tc.level) output, err := phs.Format(data) require.NoError(t, err) assert.Contains(t, output, tc.contains) }) } } func TestMessagePlaceholder(t *testing.T) { t.Parallel() phs, err := placeholders.Parse("%msg") require.NoError(t, err) data := newMinimalData("hello world", log.InfoLevel) output, err := phs.Format(data) require.NoError(t, err) assert.Equal(t, "hello world", output) } func FuzzParse(f *testing.F) { seeds := []string{ "%level", "%msg", "%time", "%interval", "%level(format=short)", "%level(format=tiny)", "plain text", "%level %msg", "%%", "%t", "%n", "%(content='hello')", "%prefix", "%tf-path", "%tf-command-args", "", "%", "%()", "%(content='unclosed", "%banana", "%level(format=full) some-text %level(format=tiny)", } for _, s := range seeds { f.Add(s) } f.Fuzz(func(t *testing.T, input string) { phs, err := placeholders.Parse(input) if err != nil { return } // If parsing succeeded, formatting should not panic data := &options.Data{ Entry: &log.Entry{ Entry: logrus.NewEntry(logrus.New()), Level: log.InfoLevel, }, DisabledColors: true, } _, _ = phs.Format(data) }) } func newMinimalData(msg string, level log.Level) *options.Data { logrusLogger := logrus.New() logrusEntry := logrus.NewEntry(logrusLogger) logrusEntry.Level = level.ToLogrusLevel() logrusEntry.Message = msg return &options.Data{ Entry: &log.Entry{ Entry: logrusEntry, Level: level, Fields: log.Fields{}, }, DisabledColors: true, } } ================================================ FILE: pkg/log/format/placeholders/plaintext.go ================================================ package placeholders import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // PlainTextPlaceholderName is the placeholder name. const PlainTextPlaceholderName = "" type plainText struct { *CommonPlaceholder } // PlainText creates a placeholder that displays plaintext. // Although plaintext can be used as is without placeholder, this allows you to format the content, // for example set a color: `%(content='just text',color=green)`. func PlainText(value string, opts ...options.Option) Placeholder { opts = WithCommonOptions( options.Content(value), ).Merge(opts...) return &plainText{ CommonPlaceholder: NewCommonPlaceholder(PlainTextPlaceholderName, opts...), } } ================================================ FILE: pkg/log/format/placeholders/time.go ================================================ package placeholders import ( "fmt" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) // TimePlaceholderName is the placeholder name. Example `%time()`. const TimePlaceholderName = "time" type timePlaceholder struct { *CommonPlaceholder } // Format implements `Placeholder` interface. func (t *timePlaceholder) Format(data *options.Data) (string, error) { return t.opts.Format(data, data.Time.String()) } // Time creates a placeholder that displays log time. func Time(opts ...options.Option) Placeholder { opts = WithCommonOptions( options.TimeFormat(fmt.Sprintf("%s:%s:%s%s", options.Hour24Zero, options.MinZero, options.SecZero, options.MilliSec)), ).Merge(opts...) return &timePlaceholder{ CommonPlaceholder: NewCommonPlaceholder(TimePlaceholderName, opts...), } } ================================================ FILE: pkg/log/formatter.go ================================================ package log import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/sirupsen/logrus" ) // Formatter is used to implement a custom Formatter. type Formatter interface { // SetDisabledColors enables/disables log colors. SetDisabledColors(val bool) // DisabledColors returns true if log colors are disabled. DisabledColors() bool // SetDisabledOutput enables/disables log output. SetDisabledOutput(val bool) // DisabledOutput returns true if log output is disabled. DisabledOutput() bool // SetBaseDir creates a set of relative paths that are used to convert full paths to relative ones. SetBaseDir(baseDir string) error // DisableRelativePaths disables the conversion of absolute paths to relative ones. DisableRelativePaths() // SetFormat parses and sets log format. SetFormat(str string) error // SetCustomFormat parses and sets custom log format. SetCustomFormat(str string) error // Format takes an `Entry`. It exposes all the fields, including the default ones: // // * `entry.Data["msg"]`. The message passed from Info, Warn, Error .. // * `entry.Data["time"]`. The timestamp. // * `entry.Data["level"]. The level the entry was logged at. // // Any additional fields added with `WithField` or `WithFields` are also in // `entry.Data`. Format is expected to return an array of bytes which are then // logged to `logger.Out`. Format(entry *Entry) ([]byte, error) } // Entry is the final logging entry. type Entry struct { *logrus.Entry Fields Fields Level Level } // fromLogrusFormatter converts call from logrus.Formatter interface to our long.Formatter interface. type fromLogrusFormatter struct { Formatter } func (f *fromLogrusFormatter) Format(parent *logrus.Entry) ([]byte, error) { if parent == nil { return nil, errors.Errorf("nil entry provided") } entry := &Entry{ Entry: parent, Level: FromLogrusLevel(parent.Level), Fields: Fields(parent.Data), } return f.Formatter.Format(entry) } ================================================ FILE: pkg/log/level.go ================================================ package log import ( "strings" "slices" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/sirupsen/logrus" ) // These are the different logging levels. const ( // StderrLevel level. Used to log error messages that we get from OpenTofu/Terraform stderr. StderrLevel Level = iota // StdoutLevel level. Used to log messages that we get from OpenTofu/Terraform stdout. StdoutLevel // ErrorLevel level. Logs. Used for errors that should definitely be noted. ErrorLevel // WarnLevel level. Non-critical entries that deserve eyes. WarnLevel // InfoLevel level. General operational entries about what's going on inside the application. InfoLevel // DebugLevel level. Usually only enabled when debugging. Very verbose logging. DebugLevel // TraceLevel level. Designates finer-grained informational events than the Debug. TraceLevel ) // Since the first two logrus levels are Panic and Fatal, which cause an exit or panic when called, we need to shift all of our levels by two bytes. const shiftLogrusLevel = 2 var logrusLevels = map[Level]logrus.Level{ StderrLevel: logrus.Level(StderrLevel + shiftLogrusLevel), StdoutLevel: logrus.Level(StdoutLevel + shiftLogrusLevel), ErrorLevel: logrus.Level(ErrorLevel + shiftLogrusLevel), WarnLevel: logrus.Level(WarnLevel + shiftLogrusLevel), InfoLevel: logrus.Level(InfoLevel + shiftLogrusLevel), DebugLevel: logrus.Level(DebugLevel + shiftLogrusLevel), TraceLevel: logrus.Level(TraceLevel + shiftLogrusLevel), } // AllLevels exposes all logging levels var AllLevels = Levels{ StderrLevel, StdoutLevel, ErrorLevel, WarnLevel, InfoLevel, DebugLevel, TraceLevel, } var levelNames = map[Level]string{ StderrLevel: "stderr", StdoutLevel: "stdout", ErrorLevel: "error", WarnLevel: "warn", InfoLevel: "info", DebugLevel: "debug", TraceLevel: "trace", } var levelShortNames = map[Level]string{ StderrLevel: "std", StdoutLevel: "std", ErrorLevel: "err", WarnLevel: "wrn", InfoLevel: "inf", DebugLevel: "deb", TraceLevel: "trc", } var levelTinyNames = map[Level]string{ StderrLevel: "s", StdoutLevel: "s", ErrorLevel: "e", WarnLevel: "w", InfoLevel: "i", DebugLevel: "d", TraceLevel: "t", } // Level type type Level uint32 // ParseLevel takes a string and returns the Level constant. func ParseLevel(str string) (Level, error) { for level, name := range levelNames { if strings.EqualFold(name, str) { return level, nil } } return Level(0), errors.Errorf("invalid level %q, supported levels: %s", str, AllLevels) } // String implements fmt.Stringer. func (level Level) String() string { return level.FullName() } // FullName returns the full level name. func (level Level) FullName() string { if name, ok := levelNames[level]; ok { return name } return "" } // TinyName returns the level name in one character. func (level Level) TinyName() string { if name, ok := levelTinyNames[level]; ok { return name } return "" } // ShortName returns the level name in third characters. func (level Level) ShortName() string { if name, ok := levelShortNames[level]; ok { return name } return "" } // UnmarshalText implements encoding.TextUnmarshaler. func (level *Level) UnmarshalText(text []byte) error { lvl, err := ParseLevel(string(text)) if err != nil { return errors.Errorf("invalid: %q", string(text)) } *level = lvl return nil } // MarshalText implements encoding.MarshalText. func (level Level) MarshalText() ([]byte, error) { if name := level.String(); name != "" { return []byte(name), nil } return nil, errors.Errorf("invalid: %q", level) } // ToLogrusLevel converts our `Level` to `logrus.Level`. func (level Level) ToLogrusLevel() logrus.Level { if logrusLevel, ok := logrusLevels[level]; ok { return logrusLevel } return logrus.Level(0) } // Levels is a slice of `Level` type. type Levels []Level // Contains returns true if the `Levels` list contains the given search `Level`. func (levels Levels) Contains(search Level) bool { return slices.Contains(levels, search) } // ToLogrusLevels converts our `Levels` to `logrus.Levels`. func (levels Levels) ToLogrusLevels() []logrus.Level { logrusLevels := make([]logrus.Level, len(levels)) for i, level := range levels { logrusLevels[i] = level.ToLogrusLevel() } return logrusLevels } // Names returns a list of full level names. func (levels Levels) Names() []string { strs := make([]string, len(levels)) for i, level := range levels { strs[i] = level.String() } return strs } // String implements the `fmt.Stringer` interface. func (levels Levels) String() string { return strings.Join(levels.Names(), ", ") } // FromLogrusLevel converts `logrus.Level` to our `Level`. func FromLogrusLevel(lvl logrus.Level) Level { for level, logrusLevel := range logrusLevels { if logrusLevel == lvl { return level } } return Level(0) } ================================================ FILE: pkg/log/level_test.go ================================================ package log_test import ( "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseLevel(t *testing.T) { t.Parallel() tests := []struct { name string input string expected log.Level expectErr bool }{ {name: "stderr", input: "stderr", expected: log.StderrLevel}, {name: "stdout", input: "stdout", expected: log.StdoutLevel}, {name: "error", input: "error", expected: log.ErrorLevel}, {name: "warn", input: "warn", expected: log.WarnLevel}, {name: "info", input: "info", expected: log.InfoLevel}, {name: "debug", input: "debug", expected: log.DebugLevel}, {name: "trace", input: "trace", expected: log.TraceLevel}, {name: "upper_INFO", input: "INFO", expected: log.InfoLevel}, {name: "mixed_Debug", input: "Debug", expected: log.DebugLevel}, {name: "upper_WARN", input: "WARN", expected: log.WarnLevel}, {name: "upper_ERROR", input: "ERROR", expected: log.ErrorLevel}, {name: "upper_TRACE", input: "TRACE", expected: log.TraceLevel}, {name: "empty", input: "", expectErr: true}, {name: "invalid_banana", input: "banana", expectErr: true}, {name: "invalid_inf", input: "inf", expectErr: true}, {name: "padded_info", input: " info ", expectErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() level, err := log.ParseLevel(tc.input) if tc.expectErr { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, level) } }) } } func TestLevelString(t *testing.T) { t.Parallel() tests := []struct { expected string level log.Level }{ {expected: "stderr", level: log.StderrLevel}, {expected: "stdout", level: log.StdoutLevel}, {expected: "error", level: log.ErrorLevel}, {expected: "warn", level: log.WarnLevel}, {expected: "info", level: log.InfoLevel}, {expected: "debug", level: log.DebugLevel}, {expected: "trace", level: log.TraceLevel}, {expected: "", level: log.Level(99)}, } for _, tc := range tests { t.Run(tc.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, tc.level.String()) }) } } func TestLevelShortName(t *testing.T) { t.Parallel() tests := []struct { expected string level log.Level }{ {expected: "std", level: log.StderrLevel}, {expected: "std", level: log.StdoutLevel}, {expected: "err", level: log.ErrorLevel}, {expected: "wrn", level: log.WarnLevel}, {expected: "inf", level: log.InfoLevel}, {expected: "deb", level: log.DebugLevel}, {expected: "trc", level: log.TraceLevel}, {expected: "", level: log.Level(99)}, } for _, tc := range tests { t.Run(tc.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, tc.level.ShortName()) }) } } func TestLevelTinyName(t *testing.T) { t.Parallel() tests := []struct { expected string level log.Level }{ {expected: "s", level: log.StderrLevel}, {expected: "s", level: log.StdoutLevel}, {expected: "e", level: log.ErrorLevel}, {expected: "w", level: log.WarnLevel}, {expected: "i", level: log.InfoLevel}, {expected: "d", level: log.DebugLevel}, {expected: "t", level: log.TraceLevel}, {expected: "", level: log.Level(99)}, } for _, tc := range tests { t.Run(tc.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, tc.level.TinyName()) }) } } func TestMarshalUnmarshalRoundTrip(t *testing.T) { t.Parallel() for _, level := range log.AllLevels { t.Run(level.String(), func(t *testing.T) { t.Parallel() data, err := level.MarshalText() require.NoError(t, err) assert.NotEmpty(t, data) var unmarshaled log.Level err = unmarshaled.UnmarshalText(data) require.NoError(t, err) assert.Equal(t, level, unmarshaled) }) } t.Run("marshal_unknown_level", func(t *testing.T) { t.Parallel() unknown := log.Level(99) _, err := unknown.MarshalText() assert.Error(t, err) }) t.Run("unmarshal_invalid_text", func(t *testing.T) { t.Parallel() var level log.Level err := level.UnmarshalText([]byte("banana")) assert.Error(t, err) }) } func TestToLogrusLevel(t *testing.T) { t.Parallel() tests := []struct { level log.Level expectedShift uint32 }{ {level: log.StderrLevel, expectedShift: 2}, {level: log.StdoutLevel, expectedShift: 3}, {level: log.ErrorLevel, expectedShift: 4}, {level: log.WarnLevel, expectedShift: 5}, {level: log.InfoLevel, expectedShift: 6}, {level: log.DebugLevel, expectedShift: 7}, {level: log.TraceLevel, expectedShift: 8}, } for _, tc := range tests { t.Run(tc.level.String(), func(t *testing.T) { t.Parallel() assert.Equal(t, logrus.Level(tc.expectedShift), tc.level.ToLogrusLevel()) }) } t.Run("unknown_level", func(t *testing.T) { t.Parallel() assert.Equal(t, logrus.Level(0), log.Level(99).ToLogrusLevel()) }) } func TestFromLogrusLevel(t *testing.T) { t.Parallel() for _, level := range log.AllLevels { t.Run(level.String(), func(t *testing.T) { t.Parallel() logrusLevel := level.ToLogrusLevel() roundTripped := log.FromLogrusLevel(logrusLevel) assert.Equal(t, level, roundTripped) }) } t.Run("unknown_logrus_level", func(t *testing.T) { t.Parallel() assert.Equal(t, log.Level(0), log.FromLogrusLevel(logrus.Level(99))) }) } func TestLevelsContains(t *testing.T) { t.Parallel() t.Run("contains_known_level", func(t *testing.T) { t.Parallel() assert.True(t, log.AllLevels.Contains(log.InfoLevel)) }) t.Run("does_not_contain_unknown_level", func(t *testing.T) { t.Parallel() assert.False(t, log.AllLevels.Contains(log.Level(99))) }) } func TestLevelsNames(t *testing.T) { t.Parallel() names := log.AllLevels.Names() assert.Len(t, names, 7) for _, name := range names { assert.NotEmpty(t, name) } } func TestLevelsString(t *testing.T) { t.Parallel() str := log.AllLevels.String() assert.Contains(t, str, ",") for _, level := range log.AllLevels { assert.Contains(t, str, level.String()) } } func TestLevelsToLogrusLevels(t *testing.T) { t.Parallel() logrusLevels := log.AllLevels.ToLogrusLevels() assert.Len(t, logrusLevels, 7) for i, logrusLevel := range logrusLevels { // Each level should be shifted by 2 from its index assert.Equal(t, logrus.Level(uint32(i)+2), logrusLevel) } } func FuzzParseLevel(f *testing.F) { // Seed with valid names, mixed case, and garbage seeds := []string{ "stderr", "stdout", "error", "warn", "info", "debug", "trace", "INFO", "Debug", "WARN", "ERROR", "TRACE", "", "banana", "inf", " info ", "12345", } for _, s := range seeds { f.Add(s) } f.Fuzz(func(t *testing.T, input string) { level, err := log.ParseLevel(input) if err == nil { // Valid level: round-trip must produce the same level reparsed, err := log.ParseLevel(level.String()) require.NoError(t, err) assert.Equal(t, level, reparsed) } }) } ================================================ FILE: pkg/log/log.go ================================================ // Package log provides a leveled logger with structured logging support. package log var ( // std is the name of the default logger. std = New() ) // Default returns the standard logger used by the package-level output functions. // Typically used as the default logger for various packages. // It is highly recommended not to use it to avoid conflicts in tests. func Default() Logger { return std } // Debug logs a message at level Debug on the standard logger. func Debug(args ...any) { std.Debug(args...) } // Trace logs a message at level Trace on the standard logger. func Trace(args ...any) { std.Trace(args...) } // Info logs a message at level Info on the standard logger. func Info(args ...any) { std.Info(args...) } // Print logs a message at level Info on the standard logger. func Print(args ...any) { std.Print(args...) } // Warn logs a message at level Warn on the standard logger. func Warn(args ...any) { std.Warn(args...) } // Error logs a message at level Error on the standard logger. func Error(args ...any) { std.Error(args...) } // Debugln logs a message at level Debug on the standard logger. func Debugln(args ...any) { std.Debugln(args...) } // Infoln logs a message at level Info on the standard logger. func Infoln(args ...any) { std.Infoln(args...) } // Println logs a message at level Info on the standard logger. func Println(args ...any) { std.Println(args...) } // Warnln logs a message at level Warn on the standard logger. func Warnln(args ...any) { std.Warnln(args...) } // Errorln logs a message at level Error on the standard logger. func Errorln(args ...any) { std.Errorln(args...) } // Debugf logs a message at level Debug on the standard logger. func Debugf(format string, args ...any) { std.Debugf(format, args...) } // Tracef logs a message at level Trace on the standard logger. func Tracef(format string, args ...any) { std.Tracef(format, args...) } // Infof logs a message at level Info on the standard logger. func Infof(format string, args ...any) { std.Infof(format, args...) } // Printf logs a message at level Info on the standard logger. func Printf(args ...any) { std.Print(args...) } // Warnf logs a message at level Warn on the standard logger. func Warnf(format string, args ...any) { std.Warnf(format, args...) } // Errorf logs a message at level Error on the standard logger. func Errorf(format string, args ...any) { std.Errorf(format, args...) } // WithField allocates a new entry and adds a field to it. func WithField(key string, value any) Logger { return std.WithField(key, value) } // WithFields adds a struct of fields to the logger. All it does is call `WithField` for each `Field`. func WithFields(fields Fields) Logger { return std.WithFields(fields) } // WithError adds an error to log entry, using the value defined in ErrorKey as key. func WithError(err error) Logger { return std.WithError(err) } // WithOptions returns a new logger with the given options. func WithOptions(opts ...Option) Logger { return std.WithOptions(opts...) } // SetOptions sets the options for the standard logger. func SetOptions(opts ...Option) { std.SetOptions(opts...) } ================================================ FILE: pkg/log/logger.go ================================================ package log import ( "context" "io" "time" "github.com/sirupsen/logrus" ) // Logger wraps the logrus package to have full control over implementing the required functionality, // such as adding or removing log levels etc. This also provides developers with an easier way to clone and set parameters. type Logger interface { // Clone creates a new Logger instance with a copy of the fields from the current one. Clone() Logger // SetOptions sets the given options to the instance. SetOptions(opts ...Option) // Level returns log level. Level() Level // SetLevel parses and sets log level. SetLevel(str string) error // SetFormatter sets the logger formatter. SetFormatter(formatter Formatter) // Formatter returns the logger formatter. Formatter() Formatter // WithOptions clones and sets the given options for the new instance. // In other words, it is a combination of two methods, `log.Clone().SetOptions(...)`, but // unlike `SetOptions(...)`, it returns the instance, which is convenient for further actions. WithOptions(opts ...Option) Logger // WithField adds a single field to the Logger and returns partly cloning instance, the `Entry` structure. // This way the field is added to the returned instance only. WithField(key string, value any) Logger // WithFields adds a struct of fields to the Logger. All it does is call `WithField` for each `Field`. WithFields(fields Fields) Logger // WithError adds an error as single field to the Logger. The error is added to the returned instance only. WithError(err error) Logger // WithContext adds a context to the Logger. The context is added to the returned instance only. WithContext(ctx context.Context) Logger // WithTime overrides the time of the Logger. This only affects the returned instance. WithTime(t time.Time) Logger // Writer returns an io.Writer that writes to the Logger at the info log level. Writer() *io.PipeWriter // WriterLevel returns an io.Writer that writes to the Logger at the given log level. WriterLevel(level Level) *io.PipeWriter // Logf logs a message at the level given as parameter on the Logger. Logf(level Level, format string, args ...any) // Tracef logs a message at level Trace on the Logger. Tracef(format string, args ...any) // Debugf logs a message at level Debug on the Logger. Debugf(format string, args ...any) // Infof logs a message at level Info on the Logger. Infof(format string, args ...any) // Printf logs a message at level Info on the Logger. Printf(format string, args ...any) // Warnf logs a message at level Warn on the Logger. Warnf(format string, args ...any) // Errorf logs a message at level Error on the Logger. Errorf(format string, args ...any) // Log logs a message at the level given as parameter on the Logger. Log(level Level, args ...any) // Trace logs a message at level Trace on the Logger. Trace(args ...any) // Debug logs a message at level Debug on the Logger. Debug(args ...any) // Info logs a message at level Info on the Logger. Info(args ...any) // Print logs a message at level Info on the Logger. Print(args ...any) // Warn logs a message at level Warn on the Logger. Warn(args ...any) // Error logs a message at level Error on the Logger. Error(args ...any) // Logln logs a message at the level given as parameter on the Logger. Logln(level Level, args ...any) // Traceln logs a message at level Trace on the Logger. Traceln(args ...any) // Debugln logs a message at level Debug on the Logger. Debugln(args ...any) // Infoln logs a message at level Info on the Logger. Infoln(args ...any) // Println logs a message at level Info on the Logger. Println(args ...any) // Warnln logs a message at level Warn on the Logger. Warnln(args ...any) // Errorln logs a message at level Error on the Logger. Errorln(args ...any) } type logger struct { *logrus.Entry formatter Formatter } // New returns a new Logger instance. func New(opts ...Option) Logger { logger := &logger{ Entry: logrus.NewEntry(logrus.New()), } logger.SetOptions(opts...) return logger } // Clone implements the Logger interface method. func (logger *logger) Clone() Logger { return logger.clone() } // SetOptions implements the Logger interface method. func (logger *logger) SetOptions(opts ...Option) { if len(opts) == 0 { return } for _, opt := range opts { opt(logger) } } // SetFormatter sets the logger formatter. func (logger *logger) SetFormatter(formatter Formatter) { logger.formatter = formatter logger.Logger.SetFormatter(&fromLogrusFormatter{Formatter: formatter}) } // SetFormatter returns the logger formatter. func (logger *logger) Formatter() Formatter { return logger.formatter } // WithOptions implements the Logger interface method. func (logger *logger) WithOptions(opts ...Option) Logger { if len(opts) == 0 { return logger } logger = logger.clone() logger.SetOptions(opts...) return logger } // Level returns log level. func (logger *logger) Level() Level { return FromLogrusLevel(logger.Logger.Level) } // SetLevel parses and sets log level. func (logger *logger) SetLevel(str string) error { level, err := ParseLevel(str) if err != nil { return err } logger.Logger.SetLevel(level.ToLogrusLevel()) return nil } // WriterLevel implements the Logger interface method. func (logger *logger) WriterLevel(level Level) *io.PipeWriter { return logger.Logger.WriterLevel(level.ToLogrusLevel()) } // // WithField implements the Logger interface method. func (logger *logger) WithField(key string, value any) Logger { return logger.WithFields(Fields{key: value}) } // WithFields implements the Logger interface method. func (logger *logger) WithFields(fields Fields) Logger { return logger.setEntry(logger.Entry.WithFields(logrus.Fields(fields))) } // WithError implements the Logger interface method. func (logger *logger) WithError(err error) Logger { return logger.setEntry(logger.Entry.WithError(err)) } // WithContext implements the Logger interface method. func (logger *logger) WithContext(ctx context.Context) Logger { return logger.setEntry(logger.Entry.WithContext(ctx)) } // WithTime implements the Logger interface method. func (logger *logger) WithTime(t time.Time) Logger { return logger.setEntry(logger.Entry.WithTime(t)) } // Logf implements the Logger interface method. func (logger *logger) Logf(level Level, format string, args ...any) { logger.Entry.Logf(level.ToLogrusLevel(), format, args...) } // Log implements the Logger interface method. func (logger *logger) Log(level Level, args ...any) { logger.Entry.Log(level.ToLogrusLevel(), args...) } // Logln implements the Logger interface method. func (logger *logger) Logln(level Level, args ...any) { logger.Entry.Logln(level.ToLogrusLevel(), args...) } // Trace implements the Logger interface method. func (logger *logger) Trace(args ...any) { logger.Log(TraceLevel, args...) } // Debug implements the Logger interface method. func (logger *logger) Debug(args ...any) { logger.Log(DebugLevel, args...) } // Print implements the Logger interface method. func (logger *logger) Print(args ...any) { logger.Info(args...) } // Info implements the Logger interface method. func (logger *logger) Info(args ...any) { logger.Log(InfoLevel, args...) } // Warn implements the Logger interface method. func (logger *logger) Warn(args ...any) { logger.Log(WarnLevel, args...) } // Error implements the Logger interface method. func (logger *logger) Error(args ...any) { logger.Log(ErrorLevel, args...) } // Entry Printf family functions. // Tracef implements the Logger interface method. func (logger *logger) Tracef(format string, args ...any) { logger.Logf(TraceLevel, format, args...) } // Debugf implements the Logger interface method. func (logger *logger) Debugf(format string, args ...any) { logger.Logf(DebugLevel, format, args...) } // Infof implements the Logger interface method. func (logger *logger) Infof(format string, args ...any) { logger.Logf(InfoLevel, format, args...) } // Printf implements the Logger interface method. func (logger *logger) Printf(format string, args ...any) { logger.Infof(format, args...) } // Warnf implements the Logger interface method. func (logger *logger) Warnf(format string, args ...any) { logger.Logf(WarnLevel, format, args...) } // Errorf implements the Logger interface method. func (logger *logger) Errorf(format string, args ...any) { logger.Logf(ErrorLevel, format, args...) } // Entry Println family functions // Traceln implements the Logger interface method. func (logger *logger) Traceln(args ...any) { logger.Logln(TraceLevel, args...) } // Debugln implements the Logger interface method. func (logger *logger) Debugln(args ...any) { logger.Logln(DebugLevel, args...) } // Infoln implements the Logger interface method. func (logger *logger) Infoln(args ...any) { logger.Logln(InfoLevel, args...) } // Println implements the Logger interface method. func (logger *logger) Println(args ...any) { logger.Infoln(args...) } // Warnln implements the Logger interface method. func (logger *logger) Warnln(args ...any) { logger.Logln(WarnLevel, args...) } // Errorln implements the Logger interface method. func (logger *logger) Errorln(args ...any) { logger.Logln(ErrorLevel, args...) } func (logger *logger) setEntry(entry *logrus.Entry) *logger { newLogger := *logger newLogger.Entry = entry return &newLogger } func (logger *logger) clone() *logger { newLogger := *logger parentLogger := newLogger.Logger newLogger.Logger = logrus.New() newLogger.Logger.SetOutput(parentLogger.Out) newLogger.Logger.SetLevel(parentLogger.Level) newLogger.Logger.SetFormatter(parentLogger.Formatter) newLogger.Logger.ReplaceHooks(parentLogger.Hooks) newLogger.Entry = newLogger.Dup() return &newLogger } ================================================ FILE: pkg/log/logger_test.go ================================================ package log_test import ( "bytes" "errors" "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNew(t *testing.T) { t.Parallel() logger := log.New() assert.NotNil(t, logger) } func TestLoggerLevelFiltering(t *testing.T) { t.Parallel() tests := []struct { name string loggerLevel log.Level msgLevel log.Level expectEmpty bool }{ {name: "info_at_info_visible", loggerLevel: log.InfoLevel, msgLevel: log.InfoLevel}, {name: "debug_at_info_hidden", loggerLevel: log.InfoLevel, msgLevel: log.DebugLevel, expectEmpty: true}, {name: "error_at_info_visible", loggerLevel: log.InfoLevel, msgLevel: log.ErrorLevel}, {name: "trace_at_trace_visible", loggerLevel: log.TraceLevel, msgLevel: log.TraceLevel}, {name: "warn_at_error_hidden", loggerLevel: log.ErrorLevel, msgLevel: log.WarnLevel, expectEmpty: true}, {name: "stderr_at_info_visible", loggerLevel: log.InfoLevel, msgLevel: log.StderrLevel}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() logger, buf := newTestLogger(tc.loggerLevel) logger.Log(tc.msgLevel, "test message") if tc.expectEmpty { assert.Empty(t, buf.String()) } else { assert.Contains(t, buf.String(), "test message") } }) } } func TestLoggerClone(t *testing.T) { t.Parallel() original := log.New(log.WithLevel(log.InfoLevel)) clone := original.Clone() assert.NotNil(t, clone) assert.Equal(t, log.InfoLevel, clone.Level()) // Clone preserves output independence: writing to clone doesn't affect a separate buffer buf := new(bytes.Buffer) cloneWithBuf := clone.WithOptions(log.WithLevel(log.DebugLevel), log.WithOutput(buf)) cloneWithBuf.Debug("clone message") assert.Contains(t, buf.String(), "clone message") } func TestLoggerWithOptions(t *testing.T) { t.Parallel() original := log.New(log.WithLevel(log.InfoLevel)) modified := original.WithOptions(log.WithLevel(log.DebugLevel)) assert.NotNil(t, modified) assert.Equal(t, log.DebugLevel, modified.Level()) } func TestLoggerSetLevel(t *testing.T) { t.Parallel() t.Run("valid_level", func(t *testing.T) { t.Parallel() logger := log.New(log.WithLevel(log.InfoLevel)) err := logger.SetLevel("debug") require.NoError(t, err) assert.Equal(t, log.DebugLevel, logger.Level()) }) t.Run("invalid_level", func(t *testing.T) { t.Parallel() logger := log.New(log.WithLevel(log.InfoLevel)) err := logger.SetLevel("banana") require.Error(t, err) assert.Equal(t, log.InfoLevel, logger.Level()) }) } func TestLoggerWithField(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) loggerWithField := logger.WithField("key", "value") loggerWithField.Info("field test") output := buf.String() assert.Contains(t, output, "field test") assert.Contains(t, output, "key") } func TestLoggerWithFields(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) loggerWithFields := logger.WithFields(log.Fields{"k1": "v1", "k2": "v2"}) loggerWithFields.Info("fields test") output := buf.String() assert.Contains(t, output, "fields test") assert.Contains(t, output, "k1") assert.Contains(t, output, "k2") } func TestLoggerWithError(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) loggerWithErr := logger.WithError(errors.New("test error")) loggerWithErr.Info("error test") output := buf.String() assert.Contains(t, output, "error test") assert.Contains(t, output, "test error") } func TestLoggerFormattedOutput(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) logger.Infof("hello %s", "world") assert.Contains(t, buf.String(), "hello world") } // newTestLogger creates a logger that writes to a buffer using the default logrus text formatter. func newTestLogger(level log.Level) (log.Logger, *bytes.Buffer) { buf := new(bytes.Buffer) logger := log.New( log.WithLevel(level), log.WithOutput(buf), ) return logger, buf } ================================================ FILE: pkg/log/options.go ================================================ package log import ( "io" "github.com/sirupsen/logrus" ) // Option is a function to set options for logger. type Option func(logger *logger) // WithLevel sets the logger level. func WithLevel(level Level) Option { return func(logger *logger) { logger.Logger.SetLevel(level.ToLogrusLevel()) } } // WithOutput sets the logger output. func WithOutput(output io.Writer) Option { return func(logger *logger) { logger.Logger.SetOutput(output) } } // WithFormatter sets the logger formatter. func WithFormatter(formatter Formatter) Option { return func(logger *logger) { logger.SetFormatter(formatter) } } // WithHooks adds hooks to the logger hooks. func WithHooks(hooks ...logrus.Hook) Option { return func(logger *logger) { for _, hook := range hooks { logger.Logger.AddHook(hook) } } } ================================================ FILE: pkg/log/util.go ================================================ package log import ( "os" "regexp" "strings" ) const ( CurDir = "." CurDirWithSeparator = CurDir + string(os.PathSeparator) // startASNISeq is the ANSI start escape sequence startASNISeq = "\033[" // resetANSISeq is the ANSI reset escape sequence resetANSISeq = "\033[0m" ansiSeq = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" ) var ( // regexp matches ansi characters getting from a shell output, used for colors etc. ansiReg = regexp.MustCompile(ansiSeq) ) // RemoveAllASCISeq returns a string with all ASCII color characters removed. func RemoveAllASCISeq(str string) string { if strings.Contains(str, startASNISeq) { str = ansiReg.ReplaceAllString(str, "") } return str } // ResetASCISeq returns a string with the ASCI color reset to the default one. func ResetASCISeq(str string) string { if strings.Contains(str, startASNISeq) { str += resetANSISeq } return str } ================================================ FILE: pkg/log/util_test.go ================================================ package log_test import ( "strings" "testing" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/stretchr/testify/assert" ) func TestRemoveAllASCISeq(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ { name: "no_ansi", input: "hello world", expected: "hello world", }, { name: "single_color_code", input: "\033[31mhello\033[0m", expected: "hello", }, { name: "bold", input: "\033[1mbold text\033[0m", expected: "bold text", }, { name: "multiple_sequences", input: "\033[31mred\033[0m and \033[32mgreen\033[0m", expected: "red and green", }, { name: "empty_string", input: "", expected: "", }, { name: "embedded_mid_string", input: "before\033[33mmiddle\033[0mafter", expected: "beforemiddleafter", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, log.RemoveAllASCISeq(tc.input)) }) } } func TestResetASCISeq(t *testing.T) { t.Parallel() tests := []struct { name string input string expected string }{ { name: "no_ansi_unchanged", input: "hello world", expected: "hello world", }, { name: "with_ansi_appends_reset", input: "\033[31mhello", expected: "\033[31mhello\033[0m", }, { name: "empty_unchanged", input: "", expected: "", }, { name: "already_has_reset", input: "\033[31mhello\033[0m", expected: "\033[31mhello\033[0m\033[0m", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tc.expected, log.ResetASCISeq(tc.input)) }) } } func FuzzRemoveAllASCISeq(f *testing.F) { f.Add("") f.Add("hello") f.Add("\033[31mred\033[0m") f.Add("\033[1;32mbold green\033[0m") f.Add("\033[") // bare/incomplete sequence f.Fuzz(func(t *testing.T, input string) { // Must never panic result := log.RemoveAllASCISeq(input) // If input had no ANSI start sequence, output equals input if !strings.Contains(input, "\033[") { assert.Equal(t, input, result) } }) } func FuzzResetASCISeq(f *testing.F) { f.Add("") f.Add("hello") f.Add("\033[31mred") f.Add("\033[1;32mbold green\033[0m") f.Fuzz(func(t *testing.T, input string) { result := log.ResetASCISeq(input) // If input contained ANSI escape, output must end with reset sequence if strings.Contains(input, "\033[") { assert.True(t, strings.HasSuffix(result, "\033[0m"), "output with ANSI sequences should end with reset") } }) } ================================================ FILE: pkg/log/writer/options.go ================================================ package writer import "github.com/gruntwork-io/terragrunt/pkg/log" // Option is a function to set options for Writer. type Option func(writer *Writer) // WithLogger sets Logger to the Writer. func WithLogger(logger log.Logger) Option { return func(writer *Writer) { writer.logger = logger } } // WithDefaultLevel sets the default log level for Writer in case the log level cannot be extracted from the message. func WithDefaultLevel(level log.Level) Option { return func(writer *Writer) { writer.defaultLevel = level } } // WithMsgSeparator configures Writer to split the received text into string and log them as separate records. func WithMsgSeparator(sep string) Option { return func(writer *Writer) { writer.msgSeparator = sep } } // WithParseFunc sets the parser func. func WithParseFunc(fn WriterParseFunc) Option { return func(writer *Writer) { writer.parseFunc = fn } } ================================================ FILE: pkg/log/writer/writer.go ================================================ // Package writer provides a writer that redirects Write requests to configured logger and level. package writer import ( "strings" "time" "github.com/gruntwork-io/terragrunt/pkg/log" ) // WriterParseFunc is a function used to parse records to extract the time and level from them. type WriterParseFunc func(str string) (msg string, time *time.Time, level *log.Level, err error) // Writer redirects Write requests to configured logger and level type Writer struct { logger log.Logger parseFunc WriterParseFunc msgSeparator string defaultLevel log.Level } // New returns a new Writer instance with fields assigned to default values. func New(opts ...Option) *Writer { writer := &Writer{ logger: log.Default(), defaultLevel: log.InfoLevel, parseFunc: func(str string) (msg string, time *time.Time, level *log.Level, err error) { return str, nil, nil, nil }, } writer.SetOption(opts...) return writer } // SetOption sets options to the `Writer`. func (writer *Writer) SetOption(opts ...Option) { for _, opt := range opts { opt(writer) } } // Write implements `io.Writer` interface. func (writer *Writer) Write(p []byte) (n int, err error) { var ( str = string(p) strs = []string{str} ) if writer.msgSeparator != "" { strs = strings.Split(str, writer.msgSeparator) } for _, str := range strs { if len(str) == 0 { continue } msg, time, level, err := writer.parseFunc(str) if err != nil { return 0, err } // Reset ANSI styles at the end of a line so that the new line does not inherit them msg = log.ResetASCISeq(msg) logger := writer.logger if time != nil { logger = logger.WithTime(*time) } if level == nil { level = &writer.defaultLevel } logger.Log(*level, msg) } return len(p), nil } ================================================ FILE: pkg/log/writer/writer_test.go ================================================ package writer_test import ( "bytes" "testing" "time" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/writer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWriterWrite(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) w := writer.New( writer.WithLogger(logger), ) n, err := w.Write([]byte("hello writer")) require.NoError(t, err) assert.Len(t, "hello writer", n) assert.Contains(t, buf.String(), "hello writer") } func TestWriterWithMsgSeparator(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) w := writer.New( writer.WithLogger(logger), writer.WithMsgSeparator("\n"), ) _, err := w.Write([]byte("line1\nline2\nline3")) require.NoError(t, err) output := buf.String() assert.Contains(t, output, "line1") assert.Contains(t, output, "line2") assert.Contains(t, output, "line3") } func TestWriterWithDefaultLevel(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.TraceLevel) w := writer.New( writer.WithLogger(logger), writer.WithDefaultLevel(log.DebugLevel), ) _, err := w.Write([]byte("debug message")) require.NoError(t, err) assert.Contains(t, buf.String(), "debug message") } func TestWriterWithParseFunc(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.TraceLevel) warnLevel := log.WarnLevel w := writer.New( writer.WithLogger(logger), writer.WithParseFunc(func(str string) (string, *time.Time, *log.Level, error) { return "parsed: " + str, nil, &warnLevel, nil }), ) _, err := w.Write([]byte("raw message")) require.NoError(t, err) assert.Contains(t, buf.String(), "parsed: raw message") } func TestWriterEmptyInput(t *testing.T) { t.Parallel() logger, buf := newTestLogger(log.InfoLevel) w := writer.New( writer.WithLogger(logger), writer.WithMsgSeparator("\n"), ) n, err := w.Write([]byte("")) require.NoError(t, err) assert.Equal(t, 0, n) assert.Empty(t, buf.String()) } func newTestLogger(level log.Level) (log.Logger, *bytes.Buffer) { buf := new(bytes.Buffer) logger := log.New( log.WithLevel(level), log.WithOutput(buf), ) return logger, buf } ================================================ FILE: pkg/options/auto_retry_options.go ================================================ package options import ( "regexp" "time" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/retry" ) // defaultErrorsConfig builds a default errorconfig.Config using retry.DefaultRetryableErrors // and default retry timings. Intended as a fallback when no errors{retry} blocks // are defined in configuration. func defaultErrorsConfig() *errorconfig.Config { compiled := make([]*errorconfig.Pattern, 0, len(retry.DefaultRetryableErrors)) for _, pat := range retry.DefaultRetryableErrors { re, err := regexp.Compile(pat) if err != nil { // Should not happen, as patterns are hardcoded and tested panic(err) } compiled = append(compiled, &errorconfig.Pattern{Pattern: re}) } cfg := &errorconfig.Config{ Retry: map[string]*errorconfig.RetryConfig{}, Ignore: map[string]*errorconfig.IgnoreConfig{}, } if len(compiled) == 0 { return cfg } cfg.Retry["default"] = &errorconfig.RetryConfig{ Name: "default", RetryableErrors: compiled, MaxAttempts: retry.DefaultMaxAttempts, SleepIntervalSec: int(retry.DefaultSleepInterval / time.Second), } return cfg } ================================================ FILE: pkg/options/options.go ================================================ // Package options provides a set of options that configure the behavior of the Terragrunt program. package options import ( "context" "encoding/json" "fmt" "io" "math" "os" "path/filepath" "time" "github.com/gruntwork-io/terragrunt/internal/cloner" "github.com/gruntwork-io/terragrunt/internal/engine" "github.com/gruntwork-io/terragrunt/internal/errorconfig" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/iam" pcoptions "github.com/gruntwork-io/terragrunt/internal/providercache/options" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/tips" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/hashicorp/go-version" "github.com/puzpuzpuz/xsync/v3" ) const ContextKey ctxKey = iota const ( DefaultMaxFoldersToCheck = 100 // no limits on parallelism by default (limited by GOPROCS) DefaultParallelism = math.MaxInt32 // TofuDefaultPath command to run tofu TofuDefaultPath = "tofu" // TerraformDefaultPath just takes terraform from the path TerraformDefaultPath = "terraform" // Default to naming it `terragrunt_rendered.json` in the terragrunt config directory. DefaultJSONOutName = "terragrunt_rendered.json" DefaultSignalsFile = "error-signals.json" DefaultTFDataDir = ".terraform" defaultExcludesFile = ".terragrunt-excludes" defaultFiltersFile = ".terragrunt-filters" DefaultLogLevel = log.InfoLevel ) var ( DefaultWrappedPath = identifyDefaultWrappedExecutable(context.Background()) defaultVersionManagerFileName = []string{ ".terraform-version", ".tool-versions", "mise.toml", ".mise.toml", } ) type ctxKey byte // TerragruntOptions represents options that configure the behavior of the Terragrunt program type TerragruntOptions struct { Writers writer.Writers // Version of terragrunt TerragruntVersion *version.Version `clone:"shadowcopy"` // FeatureFlags is a map of feature flags to enable. FeatureFlags *xsync.MapOf[string, string] `clone:"shadowcopy"` // EngineConfig holds the resolved engine configuration from HCL. EngineConfig *engine.EngineConfig // EngineOptions groups CLI-supplied engine options. EngineOptions *engine.EngineOptions // Telemetry are telemetry options. Telemetry *telemetry.Options // Attributes to override in AWS provider nested within modules as part of the aws-provider-patch command. AwsProviderPatchOverrides map[string]string // Version of terraform (obtained by running 'terraform version') TerraformVersion *version.Version `clone:"shadowcopy"` // Errors is a configuration for error handling. Errors *errorconfig.Config // Map to replace terraform source locations. SourceMap map[string]string // Environment variables at runtime Env map[string]string // StackAction is the action that should be performed on the stack. StackAction string // IAM Role options that should be used when authenticating to AWS. IAMRoleOptions iam.RoleOptions // IAM Role options set from command line. OriginalIAMRoleOptions iam.RoleOptions // Current Terraform command being executed by Terragrunt TerraformCommand string // StackOutputFormat format how the stack output is rendered. StackOutputFormat string TerragruntStackConfigPath string // Location of the original Terragrunt config file. OriginalTerragruntConfigPath string // Unlike `WorkingDir`, this path is the same for all dependencies and points to the root working directory specified in the CLI. RootWorkingDir string // Download Terraform configurations from the specified source location into a temporary folder Source string // The working directory in which to run Terraform WorkingDir string // Location (or name) of the OpenTofu/Terraform binary TFPath string // Download Terraform configurations specified in the Source parameter into this folder DownloadDir string // Original Terraform command being executed by Terragrunt. OriginalTerraformCommand string // Terraform implementation tool (e.g. terraform, tofu) that terragrunt is wrapping TofuImplementation tfimpl.Type // The file path that terragrunt should use when rendering the terragrunt.hcl config as json. JSONOut string // The command and arguments that can be used to fetch authentication configurations. AuthProviderCmd string // Folder to store JSON representation of output files. JSONOutputFolder string // Folder to store output files. OutputFolder string // The file which hclfmt should be specifically run on HclFile string // Location of the Terragrunt config file TerragruntConfigPath string // Name of the root Terragrunt configuration file, if used. ScaffoldRootFileName string // Path to a file with a list of directories that need to be excluded when running *-all commands. ExcludesFile string // Path to folder of scaffold output ScaffoldOutputFolder string // Root directory for graph command. GraphRoot string // Path to the report file. ReportFile string // Path to a file containing filter queries, one per line. Default is .terragrunt-filters. FiltersFile string // Report format. ReportFormat report.Format // Path to the report schema file. ReportSchemaFile string // CLI args that are intended for Terraform (i.e. all the CLI args except the --terragrunt ones) TerraformCliArgs *iacargs.IacArgs // Files with variables to be used in modules scaffolding. ScaffoldVarFiles []string // If set hclfmt will skip files in given directories. HclExclude []string // Variables for usage in scaffolding. ScaffoldVars []string // StrictControls is a slice of strict controls. StrictControls strict.Controls `clone:"shadowcopy"` // Filters contains parsed filter objects for component selection. Filters filter.Filters `clone:"shadowcopy"` // When set, it will be used to compute the cache key for `-version` checks. VersionManagerFileName []string // Experiments is a map of experiments, and their status. Experiments experiment.Experiments `clone:"shadowcopy"` // Tips is a collection of tips that can be shown to users. Tips tips.Tips `clone:"shadowcopy"` // ProviderCacheOptions groups all provider-cache-specific configuration. ProviderCacheOptions pcoptions.ProviderCacheOptions // Parallelism limits the number of commands to run concurrently during *-all commands Parallelism int // When searching the directory tree, this is the max folders to check before exiting with an error. MaxFoldersToCheck int // Output Terragrunt logs in JSON format JSONLogFormat bool // True if terragrunt should run in debug mode Debug bool // Disable TF output formatting ForwardTFStdout bool // Fail execution if is required to create S3 bucket FailIfBucketCreationRequired bool // FilterAllowDestroy allows destroy runs when using Git-based filters FilterAllowDestroy bool // Controls if s3 bucket should be updated or skipped DisableBucketUpdate bool // Disables validation terraform command DisableCommandValidation bool // If True then HCL from StdIn must should be formatted. HclFromStdin bool // Show diff, by default it's disabled. Diff bool // Do not include root unit in scaffolding. ScaffoldNoIncludeRoot bool // Enable check mode, by default it's disabled. Check bool // Enables caching of includes during partial parsing operations. UsePartialParseConfigCache bool // True if is required to show dependent units and confirm action CheckDependentUnits bool // True if is required to check for dependent modules during destroy operations DestroyDependenciesCheck bool // Include fields metadata in render-json RenderJSONWithMetadata bool // Whether we should automatically retry errored Terraform commands AutoRetry bool // Whether we should automatically run terraform init if necessary when executing other commands AutoInit bool // Allows to skip the output of all dependencies. SkipOutput bool // Whether we should prompt the user for confirmation or always assume "yes" NonInteractive bool // If set to true, ignore the dependency order when running *-all command. IgnoreDependencyOrder bool // If set to true, continue running *-all commands even if a dependency has errors. IgnoreDependencyErrors bool // Whether we should automatically run terraform with -auto-apply in run --all mode. RunAllAutoApprove bool // If set to true, delete the contents of the temporary folder before downloading Terraform source code into it SourceUpdate bool // HCLValidateStrict is a strict mode for HCL validation files. When it's set to false the command will only return an error if required inputs are missing from all input sources (env vars, var files, etc). When it's set to true, an error will be returned if required inputs are missing or if unused variables are passed to Terragrunt.", HCLValidateStrict bool // HCLValidateInputs checks if the terragrunt configured inputs align with the terraform defined variables. HCLValidateInputs bool // HCLValidateShowConfigPath shows the paths of the hcl invalid configs. HCLValidateShowConfigPath bool // HCLValidateJSONOutput outputs the hcl validate result as a JSON string. HCLValidateJSONOutput bool // If true, logs will be displayed in formatter key/value, by default logs are formatted in human-readable formatter. DisableLogFormatting bool // Headless is set when Terragrunt is running in headless mode. Headless bool // NoStackGenerate disable stack generation. NoStackGenerate bool // NoStackValidate disable generated stack validation. NoStackValidate bool // RunAll runs the provided OpenTofu/Terraform command against a stack. RunAll bool // Graph runs the provided OpenTofu/Terraform against the graph of dependencies for the unit in the current working directory. Graph bool // BackendBootstrap automatically bootstraps backend infrastructure before attempting to use it. BackendBootstrap bool // DeleteBucket determines whether to delete entire bucket. DeleteBucket bool // ForceBackendDelete forces the backend to be deleted, even if the bucket is not versioned. ForceBackendDelete bool // ForceBackendMigrate forces the backend to be migrated, even if the bucket is not versioned. ForceBackendMigrate bool // SummaryDisable disables the summary output at the end of a run. SummaryDisable bool // SummaryPerUnit enables showing duration information for each unit in the summary. SummaryPerUnit bool // NoAutoProviderCacheDir disables the auto-provider-cache-dir feature even when the experiment is enabled. NoAutoProviderCacheDir bool // NoDependencyFetchOutputFromState disables the dependency-fetch-output-from-state feature even when the experiment is enabled. NoDependencyFetchOutputFromState bool // TFPathExplicitlySet is set to true if the user has explicitly set the TFPath via the --tf-path flag. TFPathExplicitlySet bool // FailFast is a flag to stop execution on the first error in apply of units. FailFast bool // NoDependencyPrompt disables prompt requiring confirmation for base and leaf file dependencies when using scaffolding. NoDependencyPrompt bool // NoShell disables shell commands when using boilerplate templates in catalog and scaffold commands. NoShell bool // NoHooks disables hooks when using boilerplate templates in catalog and scaffold commands. NoHooks bool // If set, disable automatic reading of .terragrunt-filters file. NoFiltersFile bool } // TerragruntOptionsFunc is a functional option type used to pass options in certain integration tests type TerragruntOptionsFunc func(*TerragruntOptions) // WithIAMRoleARN adds the provided role ARN to IamRoleOptions func WithIAMRoleARN(arn string) TerragruntOptionsFunc { return func(t *TerragruntOptions) { t.IAMRoleOptions.RoleARN = arn } } // WithIAMWebIdentityToken adds the provided WebIdentity token to IamRoleOptions func WithIAMWebIdentityToken(token string) TerragruntOptionsFunc { return func(t *TerragruntOptions) { t.IAMRoleOptions.WebIdentityToken = token } } // NewTerragruntOptions creates a new TerragruntOptions object with // reasonable defaults for real usage func NewTerragruntOptions() *TerragruntOptions { return NewTerragruntOptionsWithWriters(os.Stdout, os.Stderr) } func NewTerragruntOptionsWithWriters(stdout, stderr io.Writer) *TerragruntOptions { return &TerragruntOptions{ Writers: writer.Writers{Writer: stdout, ErrWriter: stderr}, TFPath: DefaultWrappedPath, ExcludesFile: defaultExcludesFile, FiltersFile: defaultFiltersFile, AutoInit: true, RunAllAutoApprove: true, Env: map[string]string{}, SourceMap: map[string]string{}, TerraformCliArgs: iacargs.New(), MaxFoldersToCheck: DefaultMaxFoldersToCheck, AutoRetry: true, Parallelism: DefaultParallelism, JSONOut: DefaultJSONOutName, TofuImplementation: tfimpl.Unknown, ProviderCacheOptions: pcoptions.ProviderCacheOptions{RegistryNames: pcoptions.DefaultRegistryNames}, FeatureFlags: xsync.NewMapOf[string, string](), Errors: defaultErrorsConfig(), StrictControls: controls.New(), Experiments: experiment.NewExperiments(), Tips: tips.NewTips(), Telemetry: new(telemetry.Options), EngineOptions: new(engine.EngineOptions), VersionManagerFileName: defaultVersionManagerFileName, } } func NewTerragruntOptionsWithConfigPath(terragruntConfigPath string) (*TerragruntOptions, error) { opts := NewTerragruntOptions() // Ensure config path is absolute so downstream code can rely on it. // Skip resolution for empty paths (sentinel meaning "not set"). if terragruntConfigPath != "" { if !filepath.IsAbs(terragruntConfigPath) { absPath, err := filepath.Abs(terragruntConfigPath) if err != nil { return nil, errors.New(err) } terragruntConfigPath = absPath } terragruntConfigPath = filepath.Clean(terragruntConfigPath) } opts.TerragruntConfigPath = terragruntConfigPath workingDir, downloadDir := util.DefaultWorkingAndDownloadDirs(terragruntConfigPath) opts.WorkingDir = workingDir opts.RootWorkingDir = workingDir opts.DownloadDir = downloadDir return opts, nil } // GetDefaultIAMAssumeRoleSessionName gets the default IAM assume role session name. func GetDefaultIAMAssumeRoleSessionName() string { return fmt.Sprintf("terragrunt-%d", time.Now().UTC().UnixNano()) } // NewTerragruntOptionsForTest creates a new TerragruntOptions object with reasonable defaults for test usage. func NewTerragruntOptionsForTest(terragruntConfigPath string, options ...TerragruntOptionsFunc) (*TerragruntOptions, error) { formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders()) formatter.SetDisabledColors(true) opts, err := NewTerragruntOptionsWithConfigPath(terragruntConfigPath) if err != nil { log.WithOptions(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)).Errorf("%v\n", errors.New(err)) return nil, err } opts.NonInteractive = true for _, opt := range options { opt(opts) } return opts, nil } // OptionsFromContext tries to retrieve options from context, otherwise, returns its own instance. func (opts *TerragruntOptions) OptionsFromContext(ctx context.Context) *TerragruntOptions { if val := ctx.Value(ContextKey); val != nil { if opts, ok := val.(*TerragruntOptions); ok { return opts } } return opts } // Clone performs a deep copy of `opts` with shadow copies of: interfaces, and funcs. // Fields with "clone" tags can override this behavior. func (opts *TerragruntOptions) Clone() *TerragruntOptions { newOpts := cloner.Clone(opts) return newOpts } // CloneWithConfigPath creates a copy of this TerragruntOptions, but with different values for the given variables. This is useful for // creating a TerragruntOptions that behaves the same way, but is used for a Terraform module in a different folder. // // It also adjusts the given logger, as each cloned option has to use a working directory specific logger to enrich // log output correctly. func (opts *TerragruntOptions) CloneWithConfigPath(l log.Logger, configPath string) (log.Logger, *TerragruntOptions, error) { newOpts := opts.Clone() // Ensure configPath is absolute and normalized for consistent path handling configPath = filepath.Clean(configPath) if !filepath.IsAbs(configPath) { configPath = filepath.Clean(filepath.Join(opts.WorkingDir, configPath)) } workingDir := filepath.Dir(configPath) // Only update logger field if the working directory actually changed // This preserves any custom display path (e.g., relative path) set on the logger if workingDir != opts.WorkingDir { l = l.WithField(placeholders.WorkDirKeyName, workingDir) } newOpts.TerragruntConfigPath = configPath newOpts.WorkingDir = workingDir return l, newOpts, nil } // InsertTerraformCliArgs inserts the given argsToInsert after the terraform command argument, but before the remaining args. // Uses IacArgs parsing to properly distinguish flags from arguments. func (opts *TerragruntOptions) InsertTerraformCliArgs(argsToInsert ...string) { // Ensure TerraformCliArgs is initialized. This allows callers to use // Insert/AppendTerraformCliArgs without pre-initializing the struct, // which is common when building options incrementally or in tests. if opts.TerraformCliArgs == nil { opts.TerraformCliArgs = iacargs.New() } // Parse args using IacArgs to properly separate flags from arguments parsed := iacargs.New(argsToInsert...) // Insert flags at beginning opts.TerraformCliArgs.InsertFlag(0, parsed.Flags...) // Merge command and subcommands from parsed args opts.mergeCommandAndSubCommand(parsed) // Arguments: insert at the beginning opts.TerraformCliArgs.InsertArguments(0, parsed.Arguments...) } // mergeCommandAndSubCommand handles command and subcommand merging during arg insertion. // Command rules: // - If opts has no command, use parsed.Command // - If parsed.Command matches opts command, do nothing // - If parsed.Command is a known subcommand, add to SubCommand // - Otherwise treat parsed.Command as positional argument // // SubCommand rules: // - If parsed has explicit subcommands, use them (last writer wins) // - Otherwise keep any subcommand set during command merging func (opts *TerragruntOptions) mergeCommandAndSubCommand(parsed *iacargs.IacArgs) { // Handle command field switch { case opts.TerraformCliArgs.Command == "": opts.TerraformCliArgs.Command = parsed.Command case parsed.Command == "" || parsed.Command == opts.TerraformCliArgs.Command: // no-op case iacargs.IsKnownSubCommand(parsed.Command): opts.TerraformCliArgs.SubCommand = []string{parsed.Command} default: opts.TerraformCliArgs.InsertArguments(0, parsed.Command) } // Explicit subcommands in parsed take precedence if len(parsed.SubCommand) > 0 { opts.TerraformCliArgs.SubCommand = parsed.SubCommand } } // AppendTerraformCliArgs appends the given argsToAppend after the current TerraformCliArgs. // Uses IacArgs parsing to properly distinguish flags from arguments. func (opts *TerragruntOptions) AppendTerraformCliArgs(argsToAppend ...string) { // Ensure TerraformCliArgs is initialized. This allows callers to use // Insert/AppendTerraformCliArgs without pre-initializing the struct, // which is common when building options incrementally or in tests. if opts.TerraformCliArgs == nil { opts.TerraformCliArgs = iacargs.New() } // Parse args using IacArgs to properly separate flags from arguments parsed := iacargs.New(argsToAppend...) opts.TerraformCliArgs.AppendFlag(parsed.Flags...) // Handle parsed.Command as an argument (extra_arguments don't have a command) if parsed.Command != "" { opts.TerraformCliArgs.AppendArgument(parsed.Command) } opts.TerraformCliArgs.AppendArgument(parsed.Arguments...) // Replace subcommand if provided if len(parsed.SubCommand) > 0 { opts.TerraformCliArgs.SubCommand = parsed.SubCommand } } // TerraformDataDir returns Terraform data directory (.terraform by default, overridden by $TF_DATA_DIR envvar) func (opts *TerragruntOptions) TerraformDataDir() string { if tfDataDir, ok := opts.Env["TF_DATA_DIR"]; ok { return tfDataDir } return DefaultTFDataDir } // DataDir returns the Terraform data directory prepended with the working directory path, // or just the Terraform data directory if it is an absolute path. func (opts *TerragruntOptions) DataDir() string { tfDataDir := opts.TerraformDataDir() if filepath.IsAbs(tfDataDir) { return tfDataDir } return filepath.Join(opts.WorkingDir, tfDataDir) } // identifyDefaultWrappedExecutable returns default path used for wrapped executable. func identifyDefaultWrappedExecutable(ctx context.Context) string { if util.IsCommandExecutable(ctx, TofuDefaultPath, "-version") { return TofuDefaultPath } // fallback to Terraform if tofu is not available return TerraformDefaultPath } // RunWithErrorHandling runs the given operation and handles any errors according to the configuration. func (opts *TerragruntOptions) RunWithErrorHandling( ctx context.Context, l log.Logger, r *report.Report, operation func() error, ) error { if opts.Errors == nil { return operation() } currentAttempt := 1 // Convert working dir to a clean, absolute path for reporting. // Use directory of original config path (pre-cache location) to ensure // report runs match those created by the runner pool. reportWorkingDir := opts.WorkingDir if opts.OriginalTerragruntConfigPath != "" { reportWorkingDir = filepath.Dir(opts.OriginalTerragruntConfigPath) } reportDir := filepath.Clean(reportWorkingDir) for { err := operation() if err == nil { return nil } // Process the error through our error handling configuration action, recoveryErr := opts.Errors.AttemptErrorRecovery(l, err, currentAttempt) if recoveryErr != nil { var maxAttemptsReachedError *errorconfig.MaxAttemptsReachedError if errors.As(recoveryErr, &maxAttemptsReachedError) { return maxAttemptsReachedError } return fmt.Errorf("encountered error while attempting error recovery: %w", recoveryErr) } if action == nil { return err } if action.ShouldIgnore { l.Warnf("Ignoring error, reason: %s", action.IgnoreMessage) // Handle ignore signals if any are configured if len(action.IgnoreSignals) > 0 { if err := opts.handleIgnoreSignals(l, action.IgnoreSignals); err != nil { return err } } run, err := r.EnsureRun(l, reportDir) if err != nil { return err } if err := r.EndRun( l, run.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonErrorIgnored), report.WithCauseIgnoreBlock(action.IgnoreBlockName), ); err != nil { return err } return nil } if action.ShouldRetry { // Respect --no-auto-retry flag if !opts.AutoRetry { return err } l.Warnf( "Encountered retryable error: %s\nAttempt %d of %d. Waiting %d second(s) before retrying...", action.RetryBlockName, currentAttempt, action.RetryAttempts, action.RetrySleepSecs, ) // Record that a retry will be attempted without prematurely marking success. run, err := r.EnsureRun(l, reportDir) if err != nil { return err } if err := r.EndRun( l, run.Path, report.WithResult(report.ResultSucceeded), report.WithReason(report.ReasonRetrySucceeded), report.WithCauseRetryBlock(action.RetryBlockName), ); err != nil { return err } // Sleep before retry select { case <-time.After(time.Duration(action.RetrySleepSecs) * time.Second): // try again case <-ctx.Done(): return errors.New(ctx.Err()) } currentAttempt++ continue } return err } } func (opts *TerragruntOptions) handleIgnoreSignals(l log.Logger, signals map[string]any) error { workingDir := opts.WorkingDir signalsFile := filepath.Join(workingDir, DefaultSignalsFile) signalsJSON, err := json.MarshalIndent(signals, "", " ") if err != nil { return err } const ownerPerms = 0644 l.Warnf("Writing error signals to %s", signalsFile) if err := os.WriteFile(signalsFile, signalsJSON, ownerPerms); err != nil { return fmt.Errorf("failed to write signals file %s: %w", signalsFile, err) } return nil } ================================================ FILE: pkg/options/options_test.go ================================================ package options_test import ( "testing" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/stretchr/testify/assert" ) func TestInsertTerraformCliArgsSubcommandReplacement(t *testing.T) { t.Parallel() tc := []struct { name string initial []string insert []string expected []string }{ { name: "replace_lock_with_mirror", initial: []string{"providers", "lock", "-platform=linux_amd64"}, insert: []string{"providers", "mirror"}, expected: []string{"providers", "mirror", "-platform=linux_amd64"}, }, { name: "no_replacement_if_no_subcommand", initial: []string{"apply", "-auto-approve"}, insert: []string{"-var", "foo=bar"}, expected: []string{"apply", "-var", "foo=bar", "-auto-approve"}, }, { name: "append_new_subcommand", initial: []string{"state"}, insert: []string{"list"}, expected: []string{"state", "list"}, }, { name: "same_command_no_change", initial: []string{"apply", "-auto-approve"}, insert: []string{"apply"}, expected: []string{"apply", "-auto-approve"}, }, { name: "unknown_command_becomes_argument", initial: []string{"apply", "-auto-approve"}, insert: []string{"myplan.tfplan"}, expected: []string{"apply", "-auto-approve", "myplan.tfplan"}, }, { name: "empty_insert_no_change", initial: []string{"plan", "-out=plan.tfplan"}, insert: []string{}, expected: []string{"plan", "-out=plan.tfplan"}, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { t.Parallel() opts := &options.TerragruntOptions{ TerraformCliArgs: iacargs.New(tt.initial...), } opts.InsertTerraformCliArgs(tt.insert...) assert.Equal(t, tt.expected, opts.TerraformCliArgs.Slice()) }) } } func TestInsertTerraformCliArgsNilGuard(t *testing.T) { t.Parallel() opts := &options.TerragruntOptions{} // Should not panic opts.InsertTerraformCliArgs("plan") assert.Equal(t, []string{"plan"}, opts.TerraformCliArgs.Slice()) } ================================================ FILE: pkg/pkg.go ================================================ // Package pkg is a collection of common libraries that are used across the application. // // The purpose of this package `/pkg` is a developing a new common libraries which can be moved to `go-commons` in the future. // This approach helps us test new functionality well on a single application first and after that to safely merged with rest common libraries in `go-commons` package pkg ================================================ FILE: test/benchmarks/.gitignore ================================================ *.test ================================================ FILE: test/benchmarks/helpers/helpers.go ================================================ // Package helpers provides helper functions for the integration benchmarks. package helpers import ( "io" "os" "path/filepath" "strconv" "testing" "time" "github.com/gruntwork-io/terragrunt/internal/cli" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/require" ) const ( // DefaultDirPermissions specifies the default file mode for creating directories. // rwxr-xr-x (owner can read, write, execute; group and others can read, execute) DefaultDirPermissions = 0755 // DefaultFilePermissions specifies the default file mode for creating files. // rw-r--r-- (owner can read, write; group and others can read) DefaultFilePermissions = 0644 ) // RunTerragruntCommand runs a Terragrunt command and logs the output to io.Discard. func RunTerragruntCommand(b *testing.B, args ...string) { b.Helper() writer := io.Discard errwriter := io.Discard opts := options.NewTerragruntOptionsWithWriters(writer, errwriter) l := logger.CreateLogger().WithOptions(log.WithOutput(io.Discard)) app := cli.NewApp(l, opts) ctx := log.ContextWithLogger(b.Context(), l) err := app.RunContext(ctx, args) require.NoError(b, err) } // GenerateNUnits generates n units in the given temporary directory. func GenerateNUnits(b *testing.B, dir string, n int, tgConfig string, tfConfig string) { b.Helper() for i := range n { unitDir := filepath.Join(dir, "unit-"+strconv.Itoa(i)) require.NoError(b, os.MkdirAll(unitDir, DefaultDirPermissions)) // Create an empty `terragrunt.hcl` file unitTerragruntConfigPath := filepath.Join(unitDir, "terragrunt.hcl") require.NoError(b, os.WriteFile(unitTerragruntConfigPath, []byte(tgConfig), DefaultFilePermissions)) // Create an empty `main.tf` file unitMainTfPath := filepath.Join(unitDir, "main.tf") require.NoError(b, os.WriteFile(unitMainTfPath, []byte(tfConfig), DefaultFilePermissions)) } } // GenerateEmptyUnits generates n empty units in the given temporary directory. func GenerateEmptyUnits(b *testing.B, dir string, n int) { b.Helper() emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } ` emptyMainTf := `` rootTerragruntConfigPath := filepath.Join(dir, "root.hcl") // Create an empty `root.hcl` file require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), DefaultFilePermissions)) // Generate n units GenerateNUnits(b, dir, n, includeRootConfig, emptyMainTf) } func Init(b *testing.B, dir string) { b.Helper() // Measure plan time planStart := time.Now() RunTerragruntCommand(b, "terragrunt", "run", "--all", "init", "--non-interactive", "--working-dir", dir) planDuration := time.Since(planStart) b.ReportMetric(float64(planDuration.Seconds()), "init_s/op") } func Plan(b *testing.B, dir string) { b.Helper() // Measure plan time planStart := time.Now() RunTerragruntCommand(b, "terragrunt", "run", "--all", "plan", "--non-interactive", "--working-dir", dir) planDuration := time.Since(planStart) b.ReportMetric(float64(planDuration.Seconds()), "plan_s/op") } func Apply(b *testing.B, dir string) { b.Helper() // Track apply time applyStart := time.Now() RunTerragruntCommand(b, "terragrunt", "run", "--all", "apply", "--non-interactive", "--working-dir", dir) applyDuration := time.Since(applyStart) b.ReportMetric(float64(applyDuration.Seconds()), "apply_s/op") } func ApplyWithRunnerPool(b *testing.B, dir string) { b.Helper() // Track apply time applyStart := time.Now() RunTerragruntCommand(b, "terragrunt", "run", "--all", "apply", "--non-interactive", "--experiment", "runner-pool", "--working-dir", dir) applyDuration := time.Since(applyStart) b.ReportMetric(float64(applyDuration.Seconds()), "apply_s/op") } ================================================ FILE: test/benchmarks/integration_auto_provider_cache_dir_bench_test.go ================================================ package test_test import ( "os" "path/filepath" "strconv" "testing" "github.com/gruntwork-io/terragrunt/test/benchmarks/helpers" "github.com/stretchr/testify/require" ) // BenchmarkAutoProviderCacheDirInit benchmarks Terragrunt init with and without auto provider cache dir enabled func BenchmarkAutoProviderCacheDirInit(b *testing.B) { setup := func(tmpDir string) { fixtureSource := filepath.Join("..", "fixtures", "auto-provider-cache-dir", "heavy", "unit") terragruntConfigPath := filepath.Join(tmpDir, "terragrunt.hcl") mainTfPath := filepath.Join(tmpDir, "main.tf") originalTerragruntConfig, err := os.ReadFile(filepath.Join(fixtureSource, "terragrunt.hcl")) require.NoError(b, err) originalMainTf, err := os.ReadFile(filepath.Join(fixtureSource, "main.tf")) require.NoError(b, err) require.NoError(b, os.WriteFile(terragruntConfigPath, originalTerragruntConfig, helpers.DefaultFilePermissions)) require.NoError(b, os.WriteFile(mainTfPath, originalMainTf, helpers.DefaultFilePermissions)) helpers.RunTerragruntCommand( b, "terragrunt", "init", "--non-interactive", "--working-dir", tmpDir, ) } b.Run("init without auto provider cache dir", func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir) b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, "terragrunt", "init", "--source-update", "--non-interactive", "--working-dir", tmpDir) } b.StopTimer() }) b.Run("init with auto provider cache dir", func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir) b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, "terragrunt", "init", "--experiment", "auto-provider-cache-dir", "--source-update", "--non-interactive", "--working-dir", tmpDir) } b.StopTimer() }) } // BenchmarkProviderCachingComparison benchmarks Terragrunt init with many units // comparing no caching, provider cache server, and auto provider cache dir experiment. func BenchmarkProviderCachingComparison(b *testing.B) { setup := func(tmpDir string, count int) { fixtureSource := filepath.Join("..", "fixtures", "auto-provider-cache-dir", "heavy", "unit") originalTerragruntConfig, err := os.ReadFile(filepath.Join(fixtureSource, "terragrunt.hcl")) require.NoError(b, err) originalMainTf, err := os.ReadFile(filepath.Join(fixtureSource, "main.tf")) require.NoError(b, err) // Generate units with the provider configuration for i := range count { unitDir := filepath.Join(tmpDir, "unit-"+strconv.Itoa(i)) require.NoError(b, os.MkdirAll(unitDir, helpers.DefaultDirPermissions)) unitTerragruntConfigPath := filepath.Join(unitDir, "terragrunt.hcl") unitMainTfPath := filepath.Join(unitDir, "main.tf") require.NoError(b, os.WriteFile(unitTerragruntConfigPath, originalTerragruntConfig, helpers.DefaultFilePermissions)) require.NoError(b, os.WriteFile(unitMainTfPath, originalMainTf, helpers.DefaultFilePermissions)) } // Run initial init to avoid noise from the first iteration being slower helpers.RunTerragruntCommand( b, "terragrunt", "run", "--all", "init", "--non-interactive", "--working-dir", tmpDir, ) } counts := []int{ 1, 2, 4, 8, 16, } cacheTypes := []struct { name string args []string }{ { name: "no provider caching", args: []string{}, }, { name: "with provider cache server", args: []string{"--provider-cache"}, }, { name: "with auto provider cache dir", args: []string{"--experiment", "auto-provider-cache-dir"}, }, } for _, count := range counts { for _, cacheType := range cacheTypes { name := strconv.Itoa(count) + " units " + cacheType.name b.Run(name, func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir, count) args := make([]string, 0, 8+len(cacheType.args)) args = append(args, "terragrunt", "run", "--all", "init", "--source-update", "--non-interactive", "--working-dir", tmpDir, ) args = append(args, cacheType.args...) b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, args..., ) } b.StopTimer() }) } } } ================================================ FILE: test/benchmarks/integration_bench_test.go ================================================ package test_test import ( "fmt" "math/rand" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/test/benchmarks/helpers" "github.com/stretchr/testify/require" ) func BenchmarkEmptyTerragruntInit(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` // Create a temporary directory for the test tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") // Create an empty `root.hcl` file require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) // Create 1 units helpers.GenerateNUnits(b, tmpDir, 1, includeRootConfig, emptyMainTf) // Do an initial init to avoid noise from the first iteration being slower helpers.Init(b, tmpDir) b.Run("1 units", func(b *testing.B) { for b.Loop() { helpers.Init(b, tmpDir) } }) } func BenchmarkTwoEmptyTerragruntInits(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) helpers.GenerateNUnits(b, tmpDir, 2, includeRootConfig, emptyMainTf) // Do an initial init to avoid noise from the first iteration being slower helpers.Init(b, tmpDir) b.Run("2 units", func(b *testing.B) { for b.Loop() { helpers.Init(b, tmpDir) } }) } func BenchmarkManyEmptyTerragruntInits(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) helpers.GenerateNUnits(b, tmpDir, 1000, includeRootConfig, emptyMainTf) // Do an initial init to avoid noise from the first iteration being slower helpers.Init(b, tmpDir) b.Run("1000 units", func(b *testing.B) { for b.Loop() { helpers.Init(b, tmpDir) } }) } func BenchmarkEmptyTerragruntPlan(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` // Create a temporary directory for the test tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") // Create an empty `root.hcl` file require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) // Create 1 units helpers.GenerateNUnits(b, tmpDir, 1, includeRootConfig, emptyMainTf) helpers.Init(b, tmpDir) b.Run("1 units", func(b *testing.B) { for b.Loop() { helpers.Plan(b, tmpDir) } }) } func BenchmarkTwoEmptyTerragruntPlans(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) helpers.GenerateNUnits(b, tmpDir, 2, includeRootConfig, emptyMainTf) helpers.Init(b, tmpDir) b.Run("2 units", func(b *testing.B) { for b.Loop() { helpers.Plan(b, tmpDir) } }) } func BenchmarkManyEmptyTerragruntPlans(b *testing.B) { emptyMainTf := `` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) helpers.GenerateNUnits(b, tmpDir, 1000, includeRootConfig, emptyMainTf) helpers.Init(b, tmpDir) b.Run("1000 units", func(b *testing.B) { for b.Loop() { helpers.Plan(b, tmpDir) } }) } func BenchmarkUnitsNoDependencies(b *testing.B) { baseMainTf := `resource "null_resource" "test" { triggers = { timestamp = timestamp() } }` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) helpers.GenerateNUnits(b, tmpDir, 10, includeRootConfig, baseMainTf) helpers.Init(b, tmpDir) b.Run("default_runner", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, false, 2) b.ResetTimer() for b.Loop() { helpers.Apply(b, tmpDir) } }) b.Run("runner_pool", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, true, 2) b.ResetTimer() for b.Loop() { helpers.ApplyWithRunnerPool(b, tmpDir) } }) } func BenchmarkUnitsNoDependenciesRandomWait(b *testing.B) { emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) // Generate independent units with random 100-300ms waits for i := 0; i < 10; i++ { unitDir := filepath.Join(tmpDir, fmt.Sprintf("unit-%d", i)) require.NoError(b, os.MkdirAll(unitDir, helpers.DefaultDirPermissions)) tgPath := filepath.Join(unitDir, "terragrunt.hcl") require.NoError(b, os.WriteFile(tgPath, []byte(includeRootConfig), helpers.DefaultFilePermissions)) ms := 100 + rand.Intn(201) // 100..300 ms secs := float64(ms) / 1000.0 mainTf := fmt.Sprintf(`resource "null_resource" "wait" { provisioner "local-exec" { command = "bash -c 'sleep %.3f'" } triggers = { timestamp = timestamp() } } `, secs) tfPath := filepath.Join(unitDir, "main.tf") require.NoError(b, os.WriteFile(tfPath, []byte(mainTf), helpers.DefaultFilePermissions)) } helpers.Init(b, tmpDir) b.Run("default_runner", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, false, 2) b.ResetTimer() for b.Loop() { helpers.Apply(b, tmpDir) } }) b.Run("runner_pool", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, true, 2) b.ResetTimer() for b.Loop() { helpers.ApplyWithRunnerPool(b, tmpDir) } }) } func BenchmarkUnitsOneDependencyWithWait(b *testing.B) { baseMainTf := `resource "null_resource" "test" { triggers = { timestamp = timestamp() } }` emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` tmpDir := b.TempDir() rootTerragruntConfigPath := filepath.Join(tmpDir, "root.hcl") require.NoError(b, os.WriteFile(rootTerragruntConfigPath, []byte(emptyRootConfig), helpers.DefaultFilePermissions)) // Create units for i := 0; i < 10; i++ { unitDir := filepath.Join(tmpDir, fmt.Sprintf("unit-%d", i)) require.NoError(b, os.MkdirAll(unitDir, helpers.DefaultDirPermissions)) // terragrunt.hcl var tgConfig string if i == 2 { // unit-2 depends on unit-1 tgConfig = `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } dependencies { paths = ["../unit-1"] }` } else { tgConfig = includeRootConfig } tgPath := filepath.Join(unitDir, "terragrunt.hcl") require.NoError(b, os.WriteFile(tgPath, []byte(tgConfig), helpers.DefaultFilePermissions)) // main.tf var tfConfig string if i == 1 { // unit-1 has 400ms wait tfConfig = `resource "null_resource" "wait" { provisioner "local-exec" { command = "bash -c 'sleep 0.4'" } triggers = { timestamp = timestamp() } }` } else { tfConfig = baseMainTf } tfPath := filepath.Join(unitDir, "main.tf") require.NoError(b, os.WriteFile(tfPath, []byte(tfConfig), helpers.DefaultFilePermissions)) } helpers.Init(b, tmpDir) b.Run("default_runner", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, false, 2) b.ResetTimer() for b.Loop() { helpers.Apply(b, tmpDir) } }) b.Run("runner_pool", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, tmpDir, true, 2) b.ResetTimer() for b.Loop() { helpers.ApplyWithRunnerPool(b, tmpDir) } }) } // BenchmarkDependencyPairwiseOddDependsOnPrevEvenRandomWait generates N units (50, 100) where: // - Every odd-indexed unit depends on the previous even-indexed unit (e.g., 1->0, 3->2, ...) // - Even-indexed units perform a random sleep via local-exec to simulate workload (50..100ms) // - Odd-indexed units are no-ops but depend on their paired even unit // It measures apply times for both the default runner (configstack) and the runner pool on the SAME stack. func BenchmarkDependencyPairwiseOddDependsOnPrevEvenRandomWait(b *testing.B) { // Sizes for parameterized benchmark (2,4,8,...,128) sizes := []int{2, 4, 8, 16, 32, 64, 128} emptyRootConfig := `` includeRootConfig := `include "root" { path = find_in_parent_folders("root.hcl") } terraform { source = "." } ` for _, n := range sizes { b.Run(fmt.Sprintf("%d_units", n), func(b *testing.B) { // Generate a single stack used by both runners dir := b.TempDir() // Write root.hcl require.NoError(b, os.WriteFile(filepath.Join(dir, "root.hcl"), []byte(emptyRootConfig), helpers.DefaultFilePermissions)) // Seed random generator deterministically within this sub-benchmark rnd := rand.New(rand.NewSource(int64(n))) // Generate units where every odd depends on every even for i := 0; i < n; i++ { unitDir := filepath.Join(dir, fmt.Sprintf("unit-%d", i)) require.NoError(b, os.MkdirAll(unitDir, helpers.DefaultDirPermissions)) // terragrunt.hcl: odd units depend only on the previous even unit (i-1) var tgConfig string if i%2 == 1 { prev := i - 1 if prev >= 0 { depBlock := fmt.Sprintf("dependency \"unit_%d\" {\n config_path = \"../unit-%d\"\n}\n\n", prev, prev) tgConfig = includeRootConfig + depBlock } else { tgConfig = includeRootConfig } } else { tgConfig = includeRootConfig } tgPath := filepath.Join(unitDir, "terragrunt.hcl") require.NoError(b, os.WriteFile(tgPath, []byte(tgConfig), helpers.DefaultFilePermissions)) // main.tf: even units wait 50..100ms; odd units are no-ops var mainTf string if i%2 == 0 { // even: random sleep ms := 50 + rnd.Intn(51) // 50..100ms secs := float64(ms) / 1000.0 mainTf = fmt.Sprintf(`resource "null_resource" "wait" { provisioner "local-exec" { command = "bash -c 'sleep %.3f'" } triggers = { timestamp = timestamp() } } `, secs) } else { // odd: noop mainTf = `resource "null_resource" "noop" { triggers = { timestamp = timestamp() } } ` } tfPath := filepath.Join(unitDir, "main.tf") require.NoError(b, os.WriteFile(tfPath, []byte(mainTf), helpers.DefaultFilePermissions)) } // Init once to prepare helpers.Init(b, dir) b.Run("configstack", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, dir, false, 2) b.ResetTimer() for b.Loop() { helpers.Apply(b, dir) } }) b.Run("runner_pool", func(b *testing.B) { // Warmups (not measured) warmupApplies(b, dir, true, 2) b.ResetTimer() for b.Loop() { helpers.ApplyWithRunnerPool(b, dir) } }) }) } } // warmupApplies performs a number of unmeasured apply runs to warm caches and workers. func warmupApplies(b *testing.B, tmpDir string, useRunnerPool bool, count int) { b.Helper() for range make([]struct{}, count) { if useRunnerPool { helpers.ApplyWithRunnerPool(b, tmpDir) } else { helpers.Apply(b, tmpDir) } } } ================================================ FILE: test/benchmarks/integration_cas_bench_test.go ================================================ package test_test import ( "os" "path/filepath" "strconv" "testing" "github.com/gruntwork-io/terragrunt/test/benchmarks/helpers" "github.com/stretchr/testify/require" ) // BenchmarkCASInit benchmarks Terragrunt init with remote source with and without CAS enabled func BenchmarkCASInit(b *testing.B) { setup := func(tmpDir string) { // Copy the remote fixture content to our test directory remoteFixtureSource := filepath.Join("..", "fixtures", "download", "remote") remoteTerragruntConfigPath := filepath.Join(tmpDir, "terragrunt.hcl") // Read the original terragrunt.hcl from the remote fixture originalConfig, err := os.ReadFile(filepath.Join(remoteFixtureSource, "terragrunt.hcl")) require.NoError(b, err) // Write the config to our test directory require.NoError(b, os.WriteFile(remoteTerragruntConfigPath, originalConfig, helpers.DefaultFilePermissions)) // Run initial init to avoid noise from the first iteration being slower helpers.RunTerragruntCommand( b, "terragrunt", "init", "--experiment", "cas", "--non-interactive", "--provider-cache", "--working-dir", tmpDir, ) } b.Run("remote init without CAS", func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir) b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, "terragrunt", "init", "--non-interactive", "--provider-cache", "--source-update", "--working-dir", tmpDir) } b.StopTimer() }) b.Run("remote init with CAS", func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir) b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, "terragrunt", "init", "--experiment", "cas", "--non-interactive", "--provider-cache", "--source-update", "--working-dir", tmpDir) } b.StopTimer() }) } // BenchmarkCASWithManyUnits benchmarks Terragrunt init with many remote units with and without CAS enabled func BenchmarkCASWithManyUnits(b *testing.B) { setup := func(tmpDir string, count int) { remoteFixtureSource := filepath.Join("..", "fixtures", "download", "remote") originalConfig, err := os.ReadFile(filepath.Join(remoteFixtureSource, "terragrunt.hcl")) require.NoError(b, err) // Generate units with the remote configuration for i := range count { unitDir := filepath.Join(tmpDir, "unit-"+strconv.Itoa(i)) require.NoError(b, os.MkdirAll(unitDir, helpers.DefaultDirPermissions)) unitTerragruntConfigPath := filepath.Join(unitDir, "terragrunt.hcl") require.NoError(b, os.WriteFile(unitTerragruntConfigPath, originalConfig, helpers.DefaultFilePermissions)) } // Run initial init to avoid noise from the first iteration being slower helpers.RunTerragruntCommand( b, "terragrunt", "run", "--all", "init", "--non-interactive", "--provider-cache", "--source-update", "--working-dir", tmpDir, ) } counts := []int{ 1, 2, 4, 8, 16, 32, 64, 128, } for _, count := range counts { for _, cas := range []bool{false, true} { name := strconv.Itoa(count) + " remote units " + (func() string { if cas { return "with CAS" } return "without CAS" })() b.Run(name, func(b *testing.B) { tmpDir := b.TempDir() setup(tmpDir, count) args := []string{ "terragrunt", "run", "--all", "init", "--non-interactive", "--provider-cache", "--source-update", "--working-dir", tmpDir, } if cas { args = append(args, "--experiment", "cas") } b.ResetTimer() for b.Loop() { helpers.RunTerragruntCommand( b, args..., ) } b.StopTimer() }) } } } ================================================ FILE: test/cliconfig.go ================================================ // Package test provides integration tests for Terragrunt. package test import ( "html/template" "os" "testing" "github.com/stretchr/testify/require" ) var testCLIConfigTemplate = ` {{ if or (gt (len .FilesystemMirrorMethods) 0) (gt (len .NetworkMirrorMethods) 0) (gt (len .DirectMethods) 0) }} provider_installation { {{ if gt (len .FilesystemMirrorMethods) 0 }}{{ range $method := .FilesystemMirrorMethods }} filesystem_mirror { path = "{{ $method.Path }}" {{ if gt (len $method.Include) 0 }} include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}] {{ end }}{{ if gt (len $method.Exclude) 0 }} exclude = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}] {{ end }} } {{ end }}{{ end }} {{ if gt (len .NetworkMirrorMethods) 0 }}{{ range $method := .NetworkMirrorMethods }} network_mirror { url = "{{ $method.URL }}" {{ if gt (len $method.Include) 0 }} include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}] {{ end }}{{ if gt (len $method.Exclude) 0 }} exclude = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}] {{ end }} } {{ end }}{{ end }} {{ if gt (len .DirectMethods) 0 }}{{ range $method := .DirectMethods }} direct { {{ if gt (len $method.Include) 0 }} include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}] {{ end }}{{ if gt (len $method.Exclude) 0 }} exclude = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}] {{ end }} } {{ end }}{{ end }} } {{ end }} ` type CLIConfigProviderInstallationFilesystemMirror struct { Path string Include, Exclude []string } type CLIConfigProviderInstallationNetworkMirror struct { URL string Include, Exclude []string } type CLIConfigProviderInstallationDirect struct { Include, Exclude []string } type CLIConfigSettings struct { FilesystemMirrorMethods []CLIConfigProviderInstallationFilesystemMirror NetworkMirrorMethods []CLIConfigProviderInstallationNetworkMirror DirectMethods []CLIConfigProviderInstallationDirect } func CreateCLIConfig(t *testing.T, file *os.File, settings *CLIConfigSettings) { t.Helper() tmp, err := template.New("cliconfig").Parse(testCLIConfigTemplate) require.NoError(t, err) err = tmp.Execute(file, settings) require.NoError(t, err) } ================================================ FILE: test/fixtures/assume-role/duration/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/assume-role/duration/main.tf ================================================ resource "local_file" "test_file" { content = "test_file" filename = "${path.module}/test_file.txt" } ================================================ FILE: test/fixtures/assume-role/duration/terragrunt.hcl ================================================ remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-west-2" encrypt = true assume_role = { role_arn = "__FILL_IN_ASSUME_ROLE__" duration = "1h" } } } ================================================ FILE: test/fixtures/assume-role/external-id/terragrunt.hcl ================================================ remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" dynamodb_table = "__FILL_IN_LOCK_TABLE_NAME__" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-west-2" encrypt = true assume_role = { role_arn = get_aws_caller_identity_arn() external_id = "external_id_123" session_name = "session_name_example" } } } ================================================ FILE: test/fixtures/assume-role/external-id-with-comma/terragrunt.hcl ================================================ remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" dynamodb_table = "__FILL_IN_LOCK_TABLE_NAME__" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-west-2" encrypt = true assume_role = { role_arn = get_aws_caller_identity_arn() external_id = "external_id_123,external_id_456" session_name = "session_name_example" } } } ================================================ FILE: test/fixtures/assume-role-web-identity/file-path/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/assume-role-web-identity/file-path/main.tf ================================================ resource "local_file" "test_file" { content = "test_file" filename = "${path.module}/test_file.txt" } ================================================ FILE: test/fixtures/assume-role-web-identity/file-path/terragrunt.hcl ================================================ iam_role = "__FILL_IN_ASSUME_ROLE__" iam_web_identity_token = "__FILL_IN_IDENTITY_TOKEN_FILE_PATH__" remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" key = "${path_relative_to_include()}/terraform.tfstate" region = "__FILL_IN_REGION__" encrypt = true } } ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependency/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependency/creds.config ================================================ access_key_id=__FILL_AWS_ACCESS_KEY_ID__ secret_access_key=__FILL_AWS_SECRET_ACCESS_KEY__ session_token= tf_var_foo= ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependency/main.tf ================================================ output "foo" { value = "foo" } ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependency/terragrunt.hcl ================================================ locals { aws_account_id = get_aws_account_id() } ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependent/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependent/creds.config ================================================ access_key_id=__FILL_AWS_ACCESS_KEY_ID__ secret_access_key=__FILL_AWS_SECRET_ACCESS_KEY__ session_token= tf_var_foo= ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependent/main.tf ================================================ ================================================ FILE: test/fixtures/auth-provider-cmd/creds-for-dependency/dependent/terragrunt.hcl ================================================ dependency "dependency" { config_path = "../dependency" } ================================================ FILE: test/fixtures/auth-provider-cmd/mock-auth-cmd.sh ================================================ #!/usr/bin/env bash set -euo pipefail . "${PWD}/creds.config" json_string=$(jq -n \ --arg access_key_id "$access_key_id" \ --arg secret_access_key "$secret_access_key" \ --arg session_token "$session_token" \ --arg tf_var_foo "$tf_var_foo" \ '{awsCredentials: {ACCESS_KEY_ID: $access_key_id, SECRET_ACCESS_KEY: $secret_access_key, SESSION_TOKEN: $session_token}, envs: {TF_VAR_foo: $tf_var_foo}}') printf '%s\n' "$json_string" ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app1/creds.config ================================================ access_key_id=app1_access_key_id secret_access_key=app1_secret_access_key session_token=app1_session_token tf_var_foo=app1-bar ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app1/main.tf ================================================ variable "foo" {} variable "foo-app2" {} variable "foo-app3" {} output "foo-app1" { value = var.foo } output "foo-app2" { value = var.foo-app2 } output "foo-app3" { value = var.foo-app3 } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app1/terragrunt.hcl ================================================ include { path = find_in_parent_folders("root.hcl") } dependency "app2" { config_path = "../app2" } inputs = { foo-app2 = dependency.app2.outputs.foo-app2 foo-app3 = dependency.app2.outputs.foo-app3 } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app2/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app2/creds.config ================================================ access_key_id=app2_access_key_id secret_access_key=app2_secret_access_key session_token=app2_session_token tf_var_foo=app2-bar ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app2/main.tf ================================================ variable "foo" {} variable "foo-app3" {} output "foo-app2" { value = var.foo } output "foo-app3" { value = var.foo-app3 } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app2/terragrunt.hcl ================================================ include { path = find_in_parent_folders("root.hcl") } dependency "app3" { config_path = "../app3" } inputs = { foo-app3 = dependency.app3.outputs.foo-app3 } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app3/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app3/creds.config ================================================ access_key_id=app3_access_key_id secret_access_key=app3_secret_access_key session_token=app3_session_token tf_var_foo=app3-bar ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app3/main.tf ================================================ variable "foo" {} output "foo-app3" { value = var.foo } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/app3/terragrunt.hcl ================================================ include { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/creds.config ================================================ access_key_id=app1_access_key_id secret_access_key=app1_secret_access_key session_token=app1_session_token tf_var_foo=top-level ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/root.hcl ================================================ terraform { before_hook "before_hook" { commands = ["init"] execute = ["./test-creds.sh", get_terragrunt_dir()] working_dir = dirname(find_in_parent_folders("root.hcl")) } } ================================================ FILE: test/fixtures/auth-provider-cmd/multiple-apps/test-creds.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Use argument if provided, otherwise fallback to PWD CONFIG_DIR="${1:-${PWD}}" . "${CONFIG_DIR}/creds.config" if [[ "$access_key_id" != "$AWS_ACCESS_KEY_ID" ]]; then exit 1 fi if [[ "$secret_access_key" != "$AWS_SECRET_ACCESS_KEY" ]]; then exit 1 fi if [[ "$session_token" != "$AWS_SESSION_TOKEN" ]]; then exit 1 fi ================================================ FILE: test/fixtures/auth-provider-cmd/oidc/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/oidc/main.tf ================================================ ================================================ FILE: test/fixtures/auth-provider-cmd/oidc/mock-auth-cmd.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${AWS_TEST_OIDC_ROLE_ARN:?The AWS_TEST_OIDC_ROLE_ARN environment variable must be set.}" : "${OIDC_TOKEN:?The OIDC_TOKEN environment variable must be set.}" jq -n \ --arg role "$AWS_TEST_OIDC_ROLE_ARN" \ --arg token "$OIDC_TOKEN" \ '{awsRole: {roleARN: $role, webIdentityToken: $token}}' ================================================ FILE: test/fixtures/auth-provider-cmd/oidc/terragrunt.hcl ================================================ locals { account_id = get_aws_account_id() } ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state/creds.config ================================================ access_key_id=__FILL_AWS_ACCESS_KEY_ID__ secret_access_key=__FILL_AWS_SECRET_ACCESS_KEY__ session_token= tf_var_foo= ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state/terragrunt.hcl ================================================ locals { aws_account_id = "${get_aws_account_id()}" } remote_state { backend = "s3" generate = { path = "backend-${get_aws_account_id()}.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" key = "${path_relative_to_include()}/terraform.tfstate" region = "__FILL_IN_REGION__" encrypt = true } } ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state-w-oidc/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state-w-oidc/main.tf ================================================ ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state-w-oidc/mock-auth-cmd.sh ================================================ #!/usr/bin/env bash set -euo pipefail : "${AWS_TEST_OIDC_ROLE_ARN:?The AWS_TEST_OIDC_ROLE_ARN environment variable must be set.}" : "${OIDC_TOKEN:?The OIDC_TOKEN environment variable must be set.}" jq -n \ --arg role "$AWS_TEST_OIDC_ROLE_ARN" \ --arg token "$OIDC_TOKEN" \ '{awsRole: {roleARN: $role, webIdentityToken: $token}}' ================================================ FILE: test/fixtures/auth-provider-cmd/remote-state-w-oidc/terragrunt.hcl ================================================ remote_state { backend = "s3" generate = { path = "backend-${get_aws_account_id()}.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" key = "${path_relative_to_include()}/tofu.tfstate" region = "__FILL_IN_REGION__" encrypt = true } } ================================================ FILE: test/fixtures/auth-provider-cmd/sops/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-cmd/sops/creds.config ================================================ access_key_id=__FILL_AWS_ACCESS_KEY_ID__ secret_access_key=__FILL_AWS_SECRET_ACCESS_KEY__ session_token= tf_var_foo= ================================================ FILE: test/fixtures/auth-provider-cmd/sops/main.tf ================================================ variable "hello" {} output "hello" { value = var.hello } ================================================ FILE: test/fixtures/auth-provider-cmd/sops/secrets.json ================================================ { "data": "ENC[AES256_GCM,data:RPZ0W610wwmpQ//ETxX8aTJG2Uyfv0ArESrP3+TsqIBwKZyyZhzEg0Ae3cVIrb2OmmKIUxI5Kg7wZy3Gxw1/dzgbgkOX08eVHTZp2heVewWIOf5l0ehdMJgNAFCVM16PaK/qaUIgWeDf6F3YZZVQziVvGOaDwK+my7F6BthhbOYYBHj5cz+RxkGUeLg2sayQMrD1QGIxXFAAuZbdISBhgOI0NbEI+h1kXJPnaMHjqWVOnsD7PWJ5qb58K9YjXXtX1LecX+1oULTkDqC9/KDeg8VFYYO5lsuiKScx0PHtoYDClAuDpC4nVw==,iv:REDUyVWQZSjRXhOLEzvApMa6prEFp2G+EWNXBVLTqpo=,tag:yWitF/oplh3y4H+SJA+iBQ==,type:str]", "sops": { "kms": [ { "arn": "arn:aws:kms:us-east-1:654954254241:key/7a8b0c4e-ff3c-49d0-93ba-15e3ca3488fb", "created_at": "2025-07-24T15:58:53Z", "enc": "AQICAHhHPy++UbYHYaSeo34uZaMsxPhT+PDk7Hd1dYS/NZi4YQGYBq1EZVemmWINY2hMZL16AAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMoEWoiy4snYZ9BlEwAgEQgDt6QJiGDC3S+xdLQ5O4AetAD16vQrHfMqamy7mdmh1aFrJJkyC1U7wph/bbnaFFkGDi4VNYcjyd+yMpzg==", "aws_profile": "" } ], "gcp_kms": null, "azure_kv": null, "hc_vault": null, "age": null, "lastmodified": "2025-07-24T15:58:53Z", "mac": "ENC[AES256_GCM,data:Qotj1yITGEy3tdMXB5dlAvZAW84X3WgVQKkfg58NgSvolqcdM75w362fr3fIBMNLBZO0Su/OZNII2AYxs005qdPp8/uF+OUmxk7S//N9n/UDtpS/YrSQBMvkfsdUa3qbt8RtxBmqpdTxBSssj1kmSYy9bUS/DsCEH3FzACuDhVs=,iv:Od6dO7M93D0ERh4uqVqVFPqNvWd1M6PYM2gEPFMQCfs=,tag:MbujA94Qr+P/LMUdWJlmuw==,type:str]", "pgp": null, "unencrypted_suffix": "_unencrypted", "version": "3.9.0" } } ================================================ FILE: test/fixtures/auth-provider-cmd/sops/terragrunt.hcl ================================================ locals { data = jsondecode(jsondecode(sops_decrypt_file("secrets.json")).data) } inputs = { hello = local.data.hello } ================================================ FILE: test/fixtures/auth-provider-parallel/auth-provider.sh ================================================ #!/usr/bin/env bash # Mock auth provider that uses file-based coordination to verify parallel execution set -e # Get the directory where this script is located SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCK_DIR="${SCRIPT_DIR}/.auth-locks" mkdir -p "$LOCK_DIR" # Get a unique ID for this invocation # Use POSIX-compatible timestamp (seconds) + PID + RANDOM to ensure uniqueness # This works on Linux, macOS, and BSD without requiring nanosecond precision INVOCATION_ID="auth-$$-$(date +%s)-$RANDOM" # Create a lock file to indicate we've started # This acts as a synchronization point - by the time the file is created, # the parent process should be ready to capture our stderr output. touch "${LOCK_DIR}/start-${INVOCATION_ID}" # Log to stderr so it shows up in terragrunt output # Note: Output after lock file creation to avoid macOS stderr buffering race condition echo "Auth start ${INVOCATION_ID}" >&2 # Wait for other auth commands to also start (up to 500ms) # This ensures we test the parallel execution scenario WAIT_COUNT=0 MAX_WAIT=50 # 50 * 10ms = 500ms max wait while [[ $WAIT_COUNT -lt $MAX_WAIT ]]; do # Count how many auth commands have started STARTED=$(ls -1 "${LOCK_DIR}"/start-* 2>/dev/null | wc -l | tr -d ' \t') # If we see at least 2 others started (3 total), we know it's parallel if [[ "$STARTED" -ge 2 ]]; then echo "Auth concurrent ${INVOCATION_ID} detected=$STARTED" >&2 break fi # Sleep a bit and check again sleep 0.01 WAIT_COUNT=$((WAIT_COUNT + 1)) done # Simulate some auth work sleep 0.1 # Return fake credentials as JSON cat <&2 ================================================ FILE: test/fixtures/auth-provider-parallel/unit-a/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-parallel/unit-a/main.tf ================================================ output "unit_name" { value = "unit-a" } ================================================ FILE: test/fixtures/auth-provider-parallel/unit-a/terragrunt.hcl ================================================ # Unit A - no dependencies ================================================ FILE: test/fixtures/auth-provider-parallel/unit-b/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-parallel/unit-b/main.tf ================================================ output "unit_name" { value = "unit-b" } ================================================ FILE: test/fixtures/auth-provider-parallel/unit-b/terragrunt.hcl ================================================ # Unit B - no dependencies ================================================ FILE: test/fixtures/auth-provider-parallel/unit-c/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/auth-provider-parallel/unit-c/main.tf ================================================ output "unit_name" { value = "unit-c" } ================================================ FILE: test/fixtures/auth-provider-parallel/unit-c/terragrunt.hcl ================================================ # Unit C - no dependencies ================================================ FILE: test/fixtures/auto-provider-cache-dir/basic/unit/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.0" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/auto-provider-cache-dir/basic/unit/main.tf ================================================ terraform { required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.0" } } } ================================================ FILE: test/fixtures/auto-provider-cache-dir/basic/unit/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: test/fixtures/auto-provider-cache-dir/heavy/unit/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/datadog/datadog" { version = "3.85.0" constraints = "~> 3.0" hashes = [ "h1:9+yctZTXhDrCeUUARit7sxuQHliz4fngPPcIxQaRZQ4=", "h1:hP5Jjzzn1v8eYEm8UAiCOqbzMzpnwuyHu9fUKIRdMCI=", "h1:syzI/3cBghgn0ymfH4iKn6sEJz6pzJL2M/ZFhV+AvmU=", "zh:05812358f73ffa6a598814eef2b2ceacdba68e7ec02bfa1b8c865882cb61d713", "zh:06c1bd121347969a891e6d5ab5940601f296fff97ec0231cc96dba3b547e6159", "zh:10a3fd82798a54295662aedd20653ba4fc98c24bdf1cc89cd8428b103e384091", "zh:1ef821bd12dedd0b3f107f21c4b2f41061508a9e8262ef9b0985f1f84b3b60a9", "zh:2af26dfeccf8ca23d999dffb208601bd8afefea7a085ac9ee51021ec2df9ef20", "zh:2c05e48ab1676f1b70f0e5fc58c1e14c85b845040839be2c96fc56a25d7ede48", "zh:38d032954ac922d1e8dbbc86597c9ca8acadb4c7354f3a4be98c181985cfc51c", "zh:6d6114c9a9146dc5da0bedf9b6c7b68e90d38f3b0c545486dbc910fcc02117c1", "zh:90b3fffb13f9bc96d11cce17b5224055367696eca09dc4a4fa7ff142208809b8", "zh:a2f114e7c896ae853df98182e6d338ce7eaaba21dc2d08b9a2c65b24c41d2888", "zh:a8e9585a12ca7be57065f817e39d0c001b005f2c1752edd1e20e297c17b5f6d4", "zh:da690c516eec9cd3a50c71d1bec572e2bb47bc0d39331e68f296872701330d00", "zh:dc54a703c759dec57e42fa9bdc1510a1f81f04b99101a05649a549f457f08111", "zh:e8ac4b03359f91b0247847389fb9e2dfffdc033f16c465d4afa86e71a523d703", ] } provider "registry.opentofu.org/hashicorp/aws" { version = "6.28.0" constraints = "~> 6.0" hashes = [ "h1:i0G7vt2sNy0oz84IiuG4gplonNVyOLRdprKLurU8pe4=", "h1:tcau98fkhZ2RhbPHo8LdiiUk2RGpZUgT/t06sdMLids=", "h1:wek8vEEZpTPulbLi9xCf2wnxvc97JXAN4qcOhduSg7k=", "zh:38d58305206953783c150fb96d5c4f3ea5fe0b9e0987d927c884a6b0f2adf7a9", "zh:43fd483251165f98b7a44360b41b437d309b007ef2bfff818eedcf3730e3f5cb", "zh:4753decc5a718cb74b08244a02d00c150f0ddd6ebf2e1227f6a985c647c03ce9", "zh:5956525650554bd3fbc4b695eb5250193f0ebf94c45862a7730457ab6a315069", "zh:76d98fa1146750c01f607bae4421952ee9cd14ed3a4a59deb7136749adb9e0ae", "zh:792c29e5ec91356baddb6219ac7f6f1df09c251cbe4ab6e089fc25d64270b22a", "zh:856424380caa7c1536dc00515d12beac2693db1a8425da654eed5530abeb17d9", "zh:e8982ec2bc692efa7236e3565e7094a09f52c5b71d8860a570a36fb31a40f27f", "zh:f5e7ff825dc3f7356fb80936bfe7bb1b54a728ccf429cb753cfe590932f0403b", ] } provider "registry.opentofu.org/hashicorp/azurerm" { version = "4.58.0" constraints = "~> 4.0" hashes = [ "h1:BkBNt+rpKjPIr+DNswIe9eplWRDNso0ycLuG+lbIe+A=", "h1:DXEaadoG38C45w4xShoMGY8zGnGrh8YGVZFWf8B27fA=", "h1:IYvqMRiiYlBvwelEeOo6zKE0bo7jja9fbj620JdbU3s=", "zh:05e9a538cd705e94c20623c67825fff5f521f8489878fbe03b316ece1f716296", "zh:240bba4c06058dcf452b2a193c6b38479d8c1b20267336a9f7247ca4951ed9aa", "zh:2521de66f43433536139ba7c3d1127ac122baa2160ae839e3ebc7b99dfe82f16", "zh:2f9cc3dc5489df24042324f9b2a1b6e9e5be4df13228e78f71e3199264db02b5", "zh:44eca2e0697bcfed2c9fa2af7ff3027d3d3c50812f8eebf2fb6606f9e8b6263c", "zh:552375812c2b04dab1f6e550ed31cecfa133c590ac24bbf4606d49799b4f4a15", "zh:90781e35bd558ef994233731551e967c94251a1eb04b4a06f7994218d3e04dc9", "zh:968ffd53dbc16dde853fd88b92dca5cf3ac1951bb7a8ceb10bc2b6dcbc2fbe0e", "zh:d427ed21518fba72dd41d33e3a0c936985a6b796a27e5a3bee733e856a5d7171", ] } provider "registry.opentofu.org/hashicorp/consul" { version = "2.22.1" constraints = "~> 2.0" hashes = [ "h1:hC2//FKKZnkssQcQ1Su3OIiUcpPU7p71oFp2WIBr8DM=", "h1:iKErHXJI0g84ne56Ih/Kax560Y+GYk3wT40nBwEZRAw=", "h1:qkhEG3O7Z0wijc8CjlzL5fgTOlBuw4Brqihxr+Oyzgc=", "zh:04fe2ab9370335e24e19955db5b85977c706cdc1916edd63e9b63438b4ff1990", "zh:170c6125a71dd5920194f23dbfc411a9d24116f8c776049e5499af9b187df61a", "zh:417ad55c114bc79da09359207974aa69fba08040d114f37235e00a946326a550", "zh:5fc629534d6c5bdab364de1530a7efd7eb90952af36f7ad4f9a674b2e7428ed4", "zh:6240587dbbe3d61d57c76e55725ff7d6150b02921f53df9560e577c4e421f94a", "zh:731d3ebee059595069becd3c8cfa46ac1ebd1fba9364d6f5c67e5b50a5f60ee6", "zh:967a3e6550ea44a76c18627a83dbf02c5050498ad9e7f309ffefa746e8646349", "zh:a9df0ba4c7eb4dc7d621d23ae282eeb92c7b27fcbd9720d40636399d2c590a44", "zh:bb2ac6d4d5a600b4871025c97efd07e67de43c7bae42025bbf43e31667f86e6e", ] } provider "registry.opentofu.org/hashicorp/google" { version = "6.50.0" constraints = "~> 6.0" hashes = [ "h1:0qkP2yFo87EamHXoV0cK2w6hADP2grd+ZfzAixUPDSw=", "h1:IH3uigEekXZECc3XgxC771MS1u32uWq5RHmZtVBsau8=", "h1:MAAe4zFFdqS9M5rpmJK/vKgdb6ZMD/s/0Xd97yTDipA=", "zh:1d4695f807d998f11fcdcfa174766287b82a8093513af857bcdad2d81c642480", "zh:3173ac5df0294624d113812e49e2a55714aff7db617488168cecdf4168df9e29", "zh:34d2b3d44c23bd6354fc4ab5917b302872ea1ab8de107034567f955b1717fa5b", "zh:3a77f3cc2f3664cd5aaeeef4d044e6ec1695a079588fffec3ca03953664e5f04", "zh:6b444e4b629ea8dc8cb112a39dde098dc5584d26d6de4177558f556a9a226696", "zh:96545c8cd4d3a57069c5d1799eab5aedd887e16d98b5559a195f6d2c2d9bc674", "zh:ba464caafde95ee16671d6b5ec90f053ed77a9d06c567456db6efd9160fa3165", "zh:d876938e5b0d3f57a984d9be72467995f87fef6569968623415dc51d9f54d30b", "zh:dfd908d873e314ab807d0abc9cfd42d2611cd06dc1b9ec719ebdbb738e8e68d6", "zh:f9f16819a7738d564afd45fd169ba61004ec4e4e7089d2a4950cb8895be1fe1f", ] } provider "registry.opentofu.org/hashicorp/helm" { version = "3.1.1" constraints = "~> 3.0" hashes = [ "h1:1TvLWj0VSBgoIQy2qo0yvDTvb/7tk1t/7iQRZ9cv0CA=", "h1:8SOQHxpTUK0rYBsCoxqrvDRc75KZl9hBt1m7QLrs+QM=", "h1:brfn5YltnzexsfqpWKw+5gS9U/m77e0An3hZQamlEZk=", "zh:09b38905e234c2e0b185332819614224660050b7e4b25e9e858b593ab01adafe", "zh:09fed1b19b8bcded169fb76304e06c5b1216d5ceba92948c23384f34ddbf1fac", "zh:2e0af220f3fe79048d82f6de91752ba9929c215819d3de4f82ccb473bcd9e5df", "zh:5fe8657cbf6aca769b9565a4fb4605d7b441c2c558d915b067c0adf6f77c58d4", "zh:713943f797be3a4c6fc6bb5f1306c4f74762bfaa663f98fd8b4c49d28ee54ecf", "zh:b426458c0bbad64f9000c11af7e74a24ce9e0adb3037c05dadf80c0c3e757931", "zh:c0664866280a42156484a48f6c461d0ddb2d212da9b6e930c721ef577ab75270", "zh:e4f9d0ebb70d63d8ac3ccee00a4d8cdb15b97aaa390f95ed65921e9d0f65bfa0", "zh:f6fe7ecfafc344f4e6aecacf5ae12ac73b94389b9679dcd0f04fc5ff45bdc066", ] } provider "registry.opentofu.org/hashicorp/kubernetes" { version = "2.38.0" constraints = "~> 2.0" hashes = [ "h1:HGkB9bCmUqMRcR5/bAUOSqPBsx6DAIEnbT1fZ8vzI78=", "h1:ems+O2dA7atxMWpbtqIrsH7Oa+u+ERWSfpMaFnZPbh0=", "h1:nY7J9jFXcsRINog0KYagiWZw1GVYF9D2JmtIB7Wnrao=", "zh:1096b41c4e5b2ee6c1980916fb9a8579bc1892071396f7a9432be058aabf3cbc", "zh:2959fde9ae3d1deb5e317df0d7b02ea4977951ee6b9c4beb083c148ca8f3681c", "zh:5082f98fcb3389c73339365f7df39fc6912bf2bd1a46d5f97778f441a67fd337", "zh:620fd5d0fbc2d7a24ac6b420a4922e6093020358162a62fa8cbd37b2bac1d22e", "zh:7f47c2de179bba35d759147c53082cad6c3449d19b0ec0c5a4ca8db5b06393e1", "zh:89c3aa2a87e29febf100fd21cead34f9a4c0e6e7ae5f383b5cef815c677eb52a", "zh:96eecc9f94938a0bc35b8a63d2c4a5f972395e44206620db06760b730d0471fc", "zh:e15567c1095f898af173c281b66bffdc4f3068afdd9f84bb5b5b5521d9f29584", "zh:ecc6b912629734a9a41a7cf1c4c73fb13b4b510afc9e7b2e0011d290bcd6d77f", ] } provider "registry.opentofu.org/hashicorp/nomad" { version = "2.5.2" constraints = "~> 2.0" hashes = [ "h1:51iNOCGImmeqQQCI/6OvbFLCZvkTvDF/VcewKhBKXpg=", "h1:GLMMAUCTUmOliU8CkdgYcFr3w8TYjMneJgrP3S7QAE8=", "h1:ri3dE41H43PPzJ2drl6LVVS0mlJ1tALpxME0xxWQ34c=", "zh:2ef8139181e8855c318be2e0f1bc3180f52a675a158ea1ddb3939f9ef79202c3", "zh:33c7350e986756b0c4a2fcc5dad417823f1c4535699dad2fcb736e8838709c14", "zh:3a4fa226ceded41ca4513cd9d261eb396cb3ed3549c9708eddd2d6eb8a3c4204", "zh:3bc2956a3e25e617e2b65ee28d7f10fd330ec400a8dec8c0e50c4643b2283464", "zh:534d2101c5d349e1b4b0614e57a47a56e0f8bf126c798f93dbc8ecf7943984e6", "zh:57652799408405acb4476a2ab3b8dd022aa9d726516d3b7927aa2a097cbe9286", "zh:6b61f2b3c898979c7fd939e45509370df1292c7d377df928996b16f4768289b1", "zh:78870b227f9a90884bb47e352abdbf52bac0364ac9a5f584636a8637ab4a95f8", "zh:ceda24edada8ed297bdf88a312b3476a080396636e6a115734adc279aa009f35", ] } provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.0" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } provider "registry.opentofu.org/hashicorp/random" { version = "3.8.0" constraints = "~> 3.0" hashes = [ "h1:aEaTEHutDdKNaztKFmInhfzmZK0/OaVL8uxmncM9YF8=", "h1:ey4eBIHiuAC5xsblxtXghXE3nWwUvGqTT6KAsggiAwo=", "h1:nRPdhXsZpGPMppuUgBe/ZcAtD73NaCLGROYHXv41qz8=", "zh:2d5e0bbfac7f15595739fe54a9ab8b8eea92fd6d879706139dad7ecaa5c01c19", "zh:349e637066625d97aaa84db1b1418c86d6457cf9c5a62f6dcc3f55cbd535112c", "zh:5f4456d53f5256ccfdb87dd35d3bf34578d01bd9b71cffaf507f0692805eac8a", "zh:6c1ecfacc5f7079a068d7f8eb8924485d4ec8183f36e6318a6e748d35921ddac", "zh:6d86641edeb8c394f121f7b0a691d72f89cf9b938b987a01fc32aad396a50555", "zh:76947bd7bc7033b33980538da149c94e386f9b0abb2ce63733f25a57517e4742", "zh:79c07f4c8b3a63d9f89e25e4348b462c57e179bca66ba533710851c485e282db", "zh:ac1c2b941d994728a3a93aba093fd2202f9311d099ff85f66678897c792161ba", "zh:cbb2aa867fd828fcb4125239e00862b9a3bc2f280e945c760224276b476f4c49", ] } provider "registry.opentofu.org/hashicorp/tls" { version = "4.1.0" constraints = "~> 4.0" hashes = [ "h1:MByilNnYPdjPTlb/qcNgR0DErA6550hI6wd8OJYB1vw=", "h1:RBhHxjVu41XdAnM4WxxGTz2nYaccHNLalqx4031L8rE=", "h1:yNZuPWUgw6Ik2huf9lhsuCGONWo2rsY1MfeceT0BQpw=", "zh:187a99f0d236fd92da224e2f026c4ca8f1dcbf2b5cddc8e6896801bacfab0d73", "zh:61a32a01cc46f382014dcf7aff5bcac61fe97bd69d3ccb51c801e9437ecdb9ce", "zh:683ba18baa2cc336ff83f061b5e4569e2cd7c4a097b53a2d80bb0a26be2fc59a", "zh:85c7640ea13dcf5ae5f7f3abbf2f21e4b93ce7f333ffee5b4a6acd6b5fe71223", "zh:882f2c5214fd6d280a500acfd560925a71030ef70e10d11fa2b94815b58ae9b6", "zh:97cb5e0b81b8687870a6b8a16e9a9cfe546e2fdb7534bdd8302eda0d66393f78", "zh:c0a0110b15ce45140036fe5bf5a44cb822c2f55b30ff2770faf37d7c3cae3b5e", "zh:d98c1c63fd0c76704fd7be38c316c305a2c95f3215330f2fb1e6b0b7081bf8e9", "zh:e703a7adf220ac436f8ebfd06529de865b965fcfc461c7ef7b71afa0de04c8e9", "zh:e93e241150cd438a0708679cb4aa7976742fde02f4c1725cfdefc405c4eeca1a", ] } provider "registry.opentofu.org/hashicorp/vault" { version = "5.6.0" constraints = "~> 5.0" hashes = [ "h1:6rFaHCFAy0DoICv8MQjy+UyMVbxOVyanKLpjZNR2ZPg=", "h1:Jdm4FcgcTCtbuQNYZtwu/4aooPfyshBxMQJ3sls4udQ=", "h1:afl/41BzHl93OACbk2EnGHF/xVgHtzmEREcdejASJcM=", "zh:437c9f3920cc29d7a453aa5342270d55c618ca4979718c34641a49af1437d5a8", "zh:5aeec1c3ed1710d5594277250ae4202c97bf9d8462a3c672c689e96bec3d7c8a", "zh:71a61b7667e1016b5ed524b5842c65be1ab1661f258faf7a7cec9db4ea44abd8", "zh:769a946cc4d99752dce8db0c4e509a201c0063f266dd387164b239854392d1b7", "zh:82b0173dc9f65035b4ae6faaea497239455e5562d5d8c24a1f882e4451234dcf", "zh:8f3373ef3dd1e026424c194d46842c0451ee79aacc67d1a1087d8d1ef8e55e95", "zh:984d15ebd2fdaa55608efff7d98d59e414a1cb8c7b899b883fbbfbeb5849b70d", "zh:9b4194ab7cf28d22098c51436dbfea878e413c418d6dbcfd3689aeb580993e21", "zh:ef30a4958328b9fa3d58845ddebe8bd574a533170a28dc7b73121d52f565563b", ] } provider "registry.opentofu.org/integrations/github" { version = "6.10.2" constraints = "~> 6.0" hashes = [ "h1:0fG4EGf4A2gbz1OWMdYsdyq7+RGzlfFUlLsZmSgocNw=", "h1:FBpodFPW47rtbjMx2olf4qwXHwh42BX0Wwy7nfXdnQ8=", "h1:xuphBoJqSkQT4s20Y6afnkfVbbRfGdbPEdM8frN/41I=", "zh:0276720213c19abb83faf6774697518dc1040fd37bc83eef86634f85d4781cae", "zh:19c6a9736a3d0264c9e0064ee66f7a957f2e35af2d2b7d4a2936d6d02ae122c4", "zh:3212c3c92b2ab16feb6d99c1d2b252fef255aeb709303a2428ea3af0677c30a1", "zh:472b40f129c4e7ad2308870972c584874e018723fc190dfebe2416c5a6e6580f", "zh:721d21615de5565b5ab0b1a6cf79f3bff2c95b26289c9175fcc75947a216774d", "zh:80e9128e4da85e7d146025425aefaf20e85b6187e37c91182a19814bbc949684", "zh:83a0573eceb8f211a4f8cb9d2a98ad47c04878369046a6fcb18636cb3cbc78f3", "zh:9df2be04ec201a0d3c9251776c7eab1a8773b9ae758ebb996000e6ec7a3d72aa", "zh:a4028674e02099b2c63c73bbb51c8bebf437a5cc4537f56107852abdc2d442e7", "zh:a55dfa5f6665c63daa72bc3b50a90f2c11113c05014b2a8ae7d7f29a9ad87251", "zh:aa9af998f01ec75876fcfc9a0b4390c8f9d41dc8365e015506e7b227a0b2f042", "zh:f35e64bf0448856e21a9ecc3e551d11afdae11d44a408f124c7283354d504209", "zh:f6079518e101494fe6e62588393c739c5448e6b8b3f277da3d9b04f2088b14fe", "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", "zh:fe5835aa672c7ff876c3d327b1e1641b8d69da2f61322bd8ef4a226e40947e0f", ] } ================================================ FILE: test/fixtures/auto-provider-cache-dir/heavy/unit/main.tf ================================================ terraform { required_providers { aws = { source = "registry.opentofu.org/hashicorp/aws" version = "~> 6.0" } google = { source = "registry.opentofu.org/hashicorp/google" version = "~> 6.0" } azurerm = { source = "registry.opentofu.org/hashicorp/azurerm" version = "~> 4.0" } kubernetes = { source = "registry.opentofu.org/hashicorp/kubernetes" version = "~> 2.0" } helm = { source = "registry.opentofu.org/hashicorp/helm" version = "~> 3.0" } vault = { source = "registry.opentofu.org/hashicorp/vault" version = "~> 5.0" } consul = { source = "registry.opentofu.org/hashicorp/consul" version = "~> 2.0" } nomad = { source = "registry.opentofu.org/hashicorp/nomad" version = "~> 2.0" } datadog = { source = "registry.opentofu.org/DataDog/datadog" version = "~> 3.0" } github = { source = "registry.opentofu.org/integrations/github" version = "~> 6.0" } tls = { source = "registry.opentofu.org/hashicorp/tls" version = "~> 4.0" } random = { source = "registry.opentofu.org/hashicorp/random" version = "~> 3.0" } null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.0" } } } ================================================ FILE: test/fixtures/auto-provider-cache-dir/heavy/unit/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: test/fixtures/aws-provider-patch/example-module/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/aws" { version = "6.28.0" hashes = [ "h1:i0G7vt2sNy0oz84IiuG4gplonNVyOLRdprKLurU8pe4=", "h1:tcau98fkhZ2RhbPHo8LdiiUk2RGpZUgT/t06sdMLids=", "h1:wek8vEEZpTPulbLi9xCf2wnxvc97JXAN4qcOhduSg7k=", "zh:38d58305206953783c150fb96d5c4f3ea5fe0b9e0987d927c884a6b0f2adf7a9", "zh:43fd483251165f98b7a44360b41b437d309b007ef2bfff818eedcf3730e3f5cb", "zh:4753decc5a718cb74b08244a02d00c150f0ddd6ebf2e1227f6a985c647c03ce9", "zh:5956525650554bd3fbc4b695eb5250193f0ebf94c45862a7730457ab6a315069", "zh:76d98fa1146750c01f607bae4421952ee9cd14ed3a4a59deb7136749adb9e0ae", "zh:792c29e5ec91356baddb6219ac7f6f1df09c251cbe4ab6e089fc25d64270b22a", "zh:856424380caa7c1536dc00515d12beac2693db1a8425da654eed5530abeb17d9", "zh:e8982ec2bc692efa7236e3565e7094a09f52c5b71d8860a570a36fb31a40f27f", "zh:f5e7ff825dc3f7356fb80936bfe7bb1b54a728ccf429cb753cfe590932f0403b", ] } provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/aws-provider-patch/example-module/main.tf ================================================ # We intentionally have an AWS provider block nested within this module so that we can have an integration test that # checks if the aws-provider-patch command helps to work around https://github.com/hashicorp/terraform/issues/13018. provider "aws" { region = var.secondary_aws_region allowed_account_ids = var.allowed_account_ids alias = "secondary" } variable "secondary_aws_region" { description = "The AWS region to deploy the S3 bucket into" type = string } variable "bucket_name" { description = "The name to use for the S3 bucket" type = string } variable "allowed_account_ids" { description = "The list of IDs of AWS accounts that are allowed to run this module." type = list(string) } resource "aws_s3_bucket" "example" { bucket = var.bucket_name provider = aws.secondary # Set to true to make testing easier force_destroy = true } resource "null_resource" "complex_expression" { triggers = ( var.secondary_aws_region == "us-east-1" ? { default = "True" } : {} ) } ================================================ FILE: test/fixtures/aws-provider-patch/main.tf ================================================ provider "aws" { region = var.primary_aws_region allowed_account_ids = var.allowed_account_ids } module "example_module" { source = "github.com/gruntwork-io/terragrunt.git//test/fixtures/aws-provider-patch/example-module?ref=__BRANCH_NAME__" allowed_account_ids = var.allowed_account_ids secondary_aws_region = var.secondary_aws_region bucket_name = var.bucket_name } variable "primary_aws_region" { description = "The primary AWS region for this module" type = string } variable "secondary_aws_region" { description = "The secondary AWS region to deploy the S3 bucket from the module into" type = string } variable "allowed_account_ids" { description = "The list of IDs of AWS accounts that are allowed to run this module." type = list(string) } variable "bucket_name" { description = "The name to use for the S3 bucket" type = string } ================================================ FILE: test/fixtures/aws-provider-patch/terragrunt.hcl ================================================ # Intentionally empty. This is merely a placeholder so that Terragrunt treats this folder as a Terragrunt module. ================================================ FILE: test/fixtures/broken-dependency/app/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/broken-dependency/app/main.tf ================================================ ================================================ FILE: test/fixtures/broken-dependency/app/terragrunt.hcl ================================================ dependency "dependency" { config_path = "../dependency" mock_outputs = { test = "value" } } ================================================ FILE: test/fixtures/broken-dependency/dependency/main.tf ================================================ module "example_module" { source = "/tmp/not-existing-path" } ================================================ FILE: test/fixtures/broken-dependency/dependency/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/broken-locals/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/broken-locals/main.tf ================================================ ================================================ FILE: test/fixtures/broken-locals/terragrunt.hcl ================================================ locals { file = yamldecode(sops_decrypt_file("not-existing-file-that-will-fail-locals-evaluating.yaml")) } ================================================ FILE: test/fixtures/buffer-module-output/app/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.2.4" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/buffer-module-output/app/main.tf ================================================ terraform { required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.2.4" } } } provider "null" {} # Create a large string by repeating a smaller string multiple times resource "null_resource" "large_json" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_" ]) } } resource "null_resource" "large_json_2" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_1024" ]) } } output "large_json_output" { value = null_resource.large_json[0].triggers.large_data } output "large_json_output_2" { value = null_resource.large_json_2[0].triggers.large_data } ================================================ FILE: test/fixtures/buffer-module-output/app/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/buffer-module-output/app2/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.2.4" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/buffer-module-output/app2/main.tf ================================================ provider "null" {} terraform { required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.2.4" } } } # Create a large string by repeating a smaller string multiple times resource "null_resource" "large_json" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_" ]) } } resource "null_resource" "large_json_2" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_1024" ]) } } output "large_json_output" { value = null_resource.large_json[0].triggers.large_data } output "large_json_output_2" { value = null_resource.large_json_2[0].triggers.large_data } ================================================ FILE: test/fixtures/buffer-module-output/app2/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/buffer-module-output/app3/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.2.4" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/buffer-module-output/app3/main.tf ================================================ terraform { required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.2.4" } } } provider "null" {} # Create a large string by repeating a smaller string multiple times resource "null_resource" "large_json" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_" ]) } } resource "null_resource" "large_json_2" { count = 1 triggers = { large_data = join("", [ for i in range(0, 1024) : "ThisIsAVeryLongStringRepeatedManyTimesToCreateLargeDataBlock_1024" ]) } } output "large_json_output" { value = null_resource.large_json[0].triggers.large_data } output "large_json_output_2" { value = null_resource.large_json_2[0].triggers.large_data } ================================================ FILE: test/fixtures/buffer-module-output/app3/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex/common.hcl ================================================ locals { github_org = "gruntwork-io" } ================================================ FILE: test/fixtures/catalog/complex/dev/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/main.tf ================================================ ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/modules/terraform-aws-eks/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/modules/terraform-aws-eks/main.tf ================================================ # Intentionally empty ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/region.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex/dev/us-west-1/terragrunt.hcl ================================================ /* dfsdfsdf include "root" { path = find_in_parent_folders("root.hcl") } */ /* dfsdfsdf */ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex/prod/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex/prod/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex/root.hcl ================================================ locals { # Automatically load catalog variables shared across all accounts catalog_vars = read_terragrunt_config(find_in_parent_folders("common.hcl")) # Automatically load inputs variables shared across all accounts inputs_vars = read_terragrunt_config(find_in_parent_folders("no-exist.hcl")) # Automatically load account-level variables account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl")) # Automatically load region-level variables region_vars = read_terragrunt_config("region.hcl") } catalog { urls = [ "dev/us-west-1/modules/terraform-aws-eks", "./terraform-aws-service-catalog", "https://github.com/${local.catalog_vars.locals.github_org}/terraform-aws-utilities", ] } inputs = { github_org = local.inputs_vars.locals.github_org } ================================================ FILE: test/fixtures/catalog/complex/stage/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex/stage/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex-legacy-root/common.hcl ================================================ locals { github_org = "gruntwork-io" } ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/main.tf ================================================ ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/modules/terraform-aws-eks/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/modules/terraform-aws-eks/main.tf ================================================ # Intentionally empty ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/region.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex-legacy-root/dev/us-west-1/terragrunt.hcl ================================================ /* dfsdfsdf include "root" { path = find_in_parent_folders("root.hcl") } */ /* dfsdfsdf */ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex-legacy-root/prod/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex-legacy-root/prod/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex-legacy-root/stage/account.hcl ================================================ ================================================ FILE: test/fixtures/catalog/complex-legacy-root/stage/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/catalog/complex-legacy-root/terragrunt.hcl ================================================ locals { # Automatically load catalog variables shared across all accounts catalog_vars = read_terragrunt_config(find_in_parent_folders("common.hcl")) # Automatically load inputs variables shared across all accounts inputs_vars = read_terragrunt_config(find_in_parent_folders("no-exist.hcl")) # Automatically load account-level variables account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl")) # Automatically load region-level variables region_vars = read_terragrunt_config("region.hcl") } catalog { urls = [ "dev/us-west-1/modules/terraform-aws-eks", "./terraform-aws-service-catalog", "https://github.com/${local.catalog_vars.locals.github_org}/terraform-aws-utilities", ] } inputs = { github_org = local.inputs_vars.locals.github_org } ================================================ FILE: test/fixtures/catalog/config1.hcl ================================================ locals { baseRepo = "github.com/gruntwork-io" } catalog { urls = [ "terraform-aws-eks", "/repo-copier", "./terraform-aws-service-catalog", "/project/terragrunt/test/terraform-aws-vpc", "${local.baseRepo}/terraform-aws-lambda", ] } ================================================ FILE: test/fixtures/catalog/config2.hcl ================================================ locals { baseRepo = "github.com/gruntwork-io" } ================================================ FILE: test/fixtures/catalog/config3.hcl ================================================ catalog { } ================================================ FILE: test/fixtures/catalog/config4.hcl ================================================ locals { baseRepo = "github.com/gruntwork-io" } catalog { default_template = "/test/fixtures/scaffold/external-template" } ================================================ FILE: test/fixtures/catalog/local-template/.boilerplate/boilerplate.yml ================================================ variables: - name: EnableRootInclude description: Should include root module type: bool default: true - name: RootFileName description: Name of the root Terragrunt configuration file type: string ================================================ FILE: test/fixtures/catalog/local-template/.boilerplate/custom-template.txt ================================================ This file proves that the local template was used for scaffolding. ================================================ FILE: test/fixtures/catalog/local-template/.boilerplate/terragrunt.hcl ================================================ # Custom local template terraform { source = "{{ .sourceUrl }}" } include "root" { path = find_in_parent_folders("root.hcl") } inputs = { # This is a custom template from a local directory template_type = "local" # Required variables would be listed here if any exist {{- if .requiredVariables }} {{- range .requiredVariables }} # {{ if .Description }}{{ .Description }}{{ else }}Variable: {{ .Name }}{{ end }} # Type: {{ .Type }} {{ .Name }} = {{ .DefaultValuePlaceholder }} # TODO: fill in value {{- end }} {{- else }} # No required variables found for this module {{- end }} } ================================================ FILE: test/fixtures/catalog/local-template/app/.gitkeep ================================================ # This file ensures the app directory exists ================================================ FILE: test/fixtures/catalog/local-template/root.hcl ================================================ catalog { urls = ["."] default_template = "${get_parent_terragrunt_dir()}/.boilerplate" } ================================================ FILE: test/fixtures/catalog/terraform-aws-eks/README.md ================================================ ================================================ FILE: test/fixtures/cli-flag-hints/terragrunt.hcl ================================================ terraform { source = "tfr://registry.terraform.io/yorinasub17/terragrunt-registry-test/null//modules/one?version=0.0.2" } ================================================ FILE: test/fixtures/codegen/generate-attr/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/codegen/generate-attr/main.tf ================================================ variable "text" {} ================================================ FILE: test/fixtures/codegen/generate-attr/terragrunt.hcl ================================================ generate = { test = { path = "test.tf" if_exists = "overwrite" contents = <- xyz - path_regex: dev/secrets.+$ encrypted_regex: ^.+$ gcp_kms: >- xyz ================================================ FILE: test/fixtures/destroy-dependent-module-errors/prod/app2/terragrunt.hcl ================================================ locals { secrets = yamldecode(sops_decrypt_file("sops.yaml")) } ================================================ FILE: test/fixtures/destroy-order/app/module-a/terragrunt.hcl ================================================ inputs = { name = "Module A" } terraform { source = "../../hello" } ================================================ FILE: test/fixtures/destroy-order/app/module-b/terragrunt.hcl ================================================ inputs = { name = "Module B" } terraform { source = "../../hello" } dependencies { paths = ["../module-a"] } ================================================ FILE: test/fixtures/destroy-order/app/module-c/terragrunt.hcl ================================================ inputs = { name = "Module C" } terraform { source = "../../hello" } ================================================ FILE: test/fixtures/destroy-order/app/module-d/terragrunt.hcl ================================================ inputs = { name = "Module D" } terraform { source = "../../hello" } dependencies { paths = ["../module-c"] } ================================================ FILE: test/fixtures/destroy-order/app/module-e/terragrunt.hcl ================================================ inputs = { name = "Module E" } terraform { source = "../../hello" } ================================================ FILE: test/fixtures/destroy-order/hello/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = ">= 3.0.0" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/destroy-order/hello/hello/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/destroy-order/hello/hello/main.tf ================================================ output "hello" { value = "Hello" } ================================================ FILE: test/fixtures/destroy-order/hello/main.tf ================================================ terraform { required_version = ">= 1.5.7" required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = ">= 3.0.0" } } } variable "name" { description = "Specify a name" type = string } module "hello" { source = "./hello" } resource "null_resource" "test" { provisioner "local-exec" { command = "echo '${module.hello.hello}, ${var.name}'" } } output "test" { value = "${module.hello.hello}, ${var.name}" } ================================================ FILE: test/fixtures/destroy-warning/app-v1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/destroy-warning/app-v1/main.tf ================================================ ================================================ FILE: test/fixtures/destroy-warning/app-v1/terragrunt.hcl ================================================ dependency "vpc" { config_path = "../vpc" mock_outputs = { vpc = "mock" } } dependencies { paths = ["../vpc"] } ================================================ FILE: test/fixtures/destroy-warning/app-v2/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/destroy-warning/app-v2/main.tf ================================================ ================================================ FILE: test/fixtures/destroy-warning/app-v2/terragrunt.hcl ================================================ dependency "vpc" { config_path = "../vpc" mock_outputs = { vpc = "mock" } } dependencies { paths = ["../vpc"] } ================================================ FILE: test/fixtures/destroy-warning/root.hcl ================================================ ================================================ FILE: test/fixtures/destroy-warning/vpc/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/destroy-warning/vpc/main.tf ================================================ ================================================ FILE: test/fixtures/destroy-warning/vpc/terragrunt.hcl ================================================ include "root" { path = find_in_parent_folders("root.hcl") } ================================================ FILE: test/fixtures/detailed-exitcode/changes/app1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/detailed-exitcode/changes/app1/main.tf ================================================ resource "local_file" "example" { content = "Test" filename = "${path.module}/example.txt" } ================================================ FILE: test/fixtures/detailed-exitcode/changes/app1/terragrunt.hcl ================================================ # Intentionally empty ================================================ FILE: test/fixtures/detailed-exitcode/changes/app2/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/detailed-exitcode/changes/app2/main.tf ================================================ resource "local_file" "example" { content = "Test" filename = "${path.module}/example.txt" } ================================================ FILE: test/fixtures/detailed-exitcode/changes/app2/terragrunt.hcl ================================================ # Intentionally empty ================================================ FILE: test/fixtures/detailed-exitcode/changes-with-source/app1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/detailed-exitcode/changes-with-source/app1/main.tf ================================================ resource "local_file" "example" { content = "Test" filename = "${path.module}/example.txt" } ================================================ FILE: test/fixtures/detailed-exitcode/changes-with-source/app1/terragrunt.hcl ================================================ terraform { source = "." } ================================================ FILE: test/fixtures/detailed-exitcode/error/app1/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/detailed-exitcode/error/app1/main.tf ================================================ data "local_file" "read_not_existing_file" { filename = "${path.module}/not-existing-file.txt" } ================================================ FILE: test/fixtures/detailed-exitcode/error/app1/terragrunt.hcl ================================================ # Intentionally empty ================================================ FILE: test/fixtures/detailed-exitcode/error/app2/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/detailed-exitcode/error/app2/main.tf ================================================ resource "local_file" "example" { content = "Test" filename = "${path.module}/example.txt" } ================================================ FILE: test/fixtures/detailed-exitcode/error/app2/terragrunt.hcl ================================================ /* Sequential dependency on app1 to ensure proper test execution. This prevents flaky tests since: - Local provider returns exit code 2 for managed resources - Local provider returns exit code 1 for data sources - Only the last exit code in the sequence can be checked */ dependencies { paths = ["../app1"] } ================================================ FILE: test/fixtures/detailed-exitcode/fail-on-first-run/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/external" { version = "2.3.5" hashes = [ "h1:+OsaKKx2awgjh6j/2B3VBP6q4Dqg2Fc0uDUZll66/Hg=", "h1:VsIY+hWGvWHaGvGTSKZslY13lPeAtSTxfZRPbpLMMhs=", "h1:jcVmeuuz74tdRt2kj0MpUG9AORdlAlRRQ3k61y0r5Vc=", "zh:1fb9aca1f068374a09d438dba84c9d8ba5915d24934a72b6ef66ef6818329151", "zh:3eab30e4fcc76369deffb185b4d225999fc82d2eaaa6484d3b3164a4ed0f7c49", "zh:4f8b7a4832a68080f0bf4f155b56a691832d8a91ce8096dac0f13a90081abc50", "zh:5ff1935612db62e48e4fe6cfb83dfac401b506a5b7b38342217616fbcab70ce0", "zh:993192234d327ec86726041eb6d1efb001e41f32e4518ad8b9b162130b65ee9a", "zh:ce445e68282a2c4b2d1f994a2730406df4ea47914c0932fb4a7eb040a7ec7061", "zh:e305e17216840c54194141fb852839c2cedd6b41abd70cf8d606d6e88ed40e64", "zh:edba65fb241d663c09aa2cbf75026c840e963d5195f27000f216829e49811437", "zh:f306cc6f6ec9beaf75bdcefaadb7b77af320b1f9b56d8f50df5ebd2189a93148", "zh:fb2ff9e1f86796fda87e1f122d40568912a904da51d477461b850d81a0105f3d", ] } ================================================ FILE: test/fixtures/detailed-exitcode/fail-on-first-run/main.tf ================================================ data "external" "script" { program = [ "/bin/bash", "-c", <&2 touch .retry_marker exit 1 fi echo '{}' EOT ] } ================================================ FILE: test/fixtures/detailed-exitcode/runall-retry-after-drift/app_flaky/terragrunt.hcl ================================================ errors { retry "transient_fail" { retryable_errors = ["(?s).*transient fail.*"] max_attempts = 2 sleep_interval_sec = 1 } } ================================================ FILE: test/fixtures/dirs/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/null" { version = "3.2.4" constraints = "~> 3.2" hashes = [ "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", "h1:wg0cxqzzWFNnEzw0LSlPL9Ovqy1VFW44A7ayHEU/A1I=", "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", ] } ================================================ FILE: test/fixtures/dirs/main.tf ================================================ terraform { required_providers { null = { source = "registry.opentofu.org/hashicorp/null" version = "~> 3.2" } } } provider "null" { } ================================================ FILE: test/fixtures/dirs/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/disabled/app/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/disabled/app/main.tf ================================================ ================================================ FILE: test/fixtures/disabled/app/terragrunt.hcl ================================================ dependency "unit_without_enabled" { config_path = "../unit-without-enabled" mock_outputs = { "output1" = "mocked_output1" } } dependency "unit_disabled" { config_path = "../unit-disabled" enabled = false mock_outputs = { "output2" = "mocked_output2" } } dependency "unit_enabled" { config_path = "../unit-enabled" enabled = true mock_outputs = { "output3" = "mocked_output3" } } dependency "unit_missing" { config_path = "../unit-missing" enabled = false } ================================================ FILE: test/fixtures/disabled/unit-disabled/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/disabled/unit-disabled/main.tf ================================================ ================================================ FILE: test/fixtures/disabled/unit-disabled/terragrunt.hcl ================================================ broken hcl file ================================================ FILE: test/fixtures/disabled/unit-enabled/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/disabled/unit-enabled/main.tf ================================================ ================================================ FILE: test/fixtures/disabled/unit-enabled/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/disabled/unit-without-enabled/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/disabled/unit-without-enabled/main.tf ================================================ ================================================ FILE: test/fixtures/disabled/unit-without-enabled/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/disabled-path/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. ================================================ FILE: test/fixtures/disabled-path/main.tf ================================================ ================================================ FILE: test/fixtures/disabled-path/terragrunt.hcl ================================================ terraform { source = "/dev/null" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-01/foo/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-01/foo/main.tf ================================================ resource "local_file" "file" { content = "Hello, World!" filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-01/foo/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/docs/01-quick-start/step-01.1/foo/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-01.1/foo/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-01.1/foo/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/bar/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/bar/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/bar/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/foo/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/foo/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-02/foo/terragrunt.hcl ================================================ ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/bar/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/bar/main.tf ================================================ variable "content" {} module "shared" { source = "../shared" content = var.content } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/bar/terragrunt.hcl ================================================ terraform { source = "..//bar" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/foo/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/foo/main.tf ================================================ variable "content" {} module "shared" { source = "../shared" content = var.content } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/foo/terragrunt.hcl ================================================ terraform { source = "..//foo" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-03/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-04/.gitignore ================================================ .terragrunt-cache ================================================ FILE: test/fixtures/docs/01-quick-start/step-04/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from bar, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-04/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-04/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-04/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/.gitignore ================================================ .terragrunt-cache ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/README.md ================================================ Note that this step is the same as the previous step. ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from bar, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/.gitignore ================================================ .terragrunt-cache hi.txt ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/README.md ================================================ This example demonstrates how to control output file locations using get_terragrunt_dir(). The hi.txt files will be created directly in the foo and bar directories instead of the .terragrunt-cache directory. ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { output_dir = get_terragrunt_dir() content = "Hello from bar, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { output_dir = get_terragrunt_dir() content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-05.1/shared/main.tf ================================================ variable "content" {} variable "output_dir" {} resource "local_file" "file" { content = var.content filename = "${var.output_dir}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/.gitignore ================================================ .terragrunt-cache ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } dependency "foo" { config_path = "../foo" } inputs = { content = "Foo content: ${dependency.foo.outputs.content}" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-06/shared/output.tf ================================================ output "content" { value = local_file.file.content } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/.gitignore ================================================ .terragrunt-cache ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } dependency "foo" { config_path = "../foo" mock_outputs = { content = "Mocked content from foo" } } inputs = { content = "Foo content: ${dependency.foo.outputs.content}" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07/shared/output.tf ================================================ output "content" { value = local_file.file.content } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/.gitignore ================================================ .terragrunt-cache ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/bar/terragrunt.hcl ================================================ terraform { source = "../shared" } dependency "foo" { config_path = "../foo" mock_outputs = { content = "Mocked content from foo" } mock_outputs_allowed_terraform_commands = ["plan"] } inputs = { content = "Foo content: ${dependency.foo.outputs.content}" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/foo/terragrunt.hcl ================================================ terraform { source = "../shared" } inputs = { content = "Hello from foo, Terragrunt!" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/shared/.terraform.lock.hcl ================================================ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/local" { version = "2.6.1" hashes = [ "h1:+XfQ7VmNtYMp0eOnoQH6cZpSMk12IP1X6tEkMoMGQ/A=", "h1:Dd5MP04TnE9qaFD8BQkJYkluiJCOsL7fwUTJx26KIP0=", "h1:QH/Ay/SWVoOLgvFacjcvQcrw2WfEktZHxCcIQG0A9/w=", "zh:0416d7bf0b459a995cf48f202af7b7ffa252def7d23386fc05b34f67347a22ba", "zh:24743d559026b59610eb3d9fa9ec7fbeb06399c0ef01272e46fe5c313eb5c6ff", "zh:2561cdfbc90090fee7f844a5cb5cbed8472ce264f5d505acb18326650a5b563f", "zh:3ebc3f2dc7a099bd83e5c4c2b6918e5b56ec746766c58a31a3f5d189cb837db5", "zh:490e0ce925fc3848027e10017f960e9e19e7f9c3b620524f67ce54217d1c6390", "zh:bf08934295877f831f2e5f17a0b3ebb51dd608b2509077f7b22afa7722e28950", "zh:c298c0f72e1485588a73768cb90163863b6c3d4c71982908c219e9b87904f376", "zh:cedbaed4967818903ef378675211ed541c8243c4597304161363e828c7dc3d36", "zh:edda76726d7874128cf1e182640c332c5a5e6a66a053c0aa97e2a0e4267b3b92", ] } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/shared/main.tf ================================================ variable "content" {} resource "local_file" "file" { content = var.content filename = "${path.module}/hi.txt" } ================================================ FILE: test/fixtures/docs/01-quick-start/step-07.1/shared/output.tf ================================================ output "content" { value = local_file.file.content } ================================================ FILE: test/fixtures/docs/02-overview/step-01-terragrunt.hcl/terragrunt.hcl ================================================ # Configure the remote backend remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { bucket = "__FILL_IN_BUCKET_NAME__" key = "tofu.tfstate" region = "__FILL_IN_REGION__" encrypt = true dynamodb_table = "__FILL_IN_LOCK_TABLE_NAME__" } } # Configure the AWS provider generate "provider" { path = "provider.tf" if_exists = "overwrite_terragrunt" contents = <