Skip to content

[WIP] feat: Support version constraints for Terraform Registry modules #3926

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/_docs/04_reference/04-config-blocks-and-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ The `terraform` block supports the following arguments:
- Refer to [A note about using modules from the
registry]({{site.baseurl}}/docs/getting-started/quick-start#a-note-about-using-modules-from-the-registry) for more
information about using modules from the Terraform Registry with Terragrunt.
- VERSION can be a specific version number (e.g., `1.2.3`) or a version constraint (e.g., `>= 1.2.3, <=3.2.1`) following syntax
defined in the [OpenTofu documentation](https://opentofu.org/docs/language/expressions/version-constraints/).

- `include_in_copy` (attribute): A list of glob patterns (e.g., `["*.txt"]`) that should always be copied into the
OpenTofu/Terraform working directory. When you use the `source` param in your Terragrunt config and run `terragrunt <command>`,
Expand Down
8 changes: 8 additions & 0 deletions test/fixtures/tfr/version/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Retrieve a module from the public terraform registry to use with terragrunt
locals {
base_source = "tfr:///terraform-aws-modules/iam/aws"
version = "~>5.0, <5.51.0, !=5.50.0"
}
terraform {
source = "${local.base_source}?version=${local.version}"
}
10 changes: 10 additions & 0 deletions test/integration_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
registryFixtureRootShorthandModulePath = "root-shorthand"
registryFixtureSubdirModulePath = "subdir"
registryFixtureSubdirWithReferenceModulePath = "subdir-with-reference"
registryFixtureVersion = "version"
)

func TestTerraformRegistryFetchingRootModule(t *testing.T) {
Expand Down Expand Up @@ -55,5 +56,14 @@ func testTerraformRegistryFetching(t *testing.T, modPath, expectedOutputKey stri
require.NoError(t, json.Unmarshal(stdout.Bytes(), &outputs))
_, hasOutput := outputs[expectedOutputKey]
assert.True(t, hasOutput)
}

func TestTerraformRegistryVersionResolution(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

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

I think we need a test for the error case too, current tests only cover happy path and won't catch regression in error handling.

Copy link
Author

Choose a reason for hiding this comment

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

Sure, I'm completely open to it. Do you have any idea or clarification about the path I should follow? In fact I tried to make the test more accurate by reading the stdout and trying to read the info log-line where the version picked was shown (to check the picked version), but I discovered logs are at a different level and this was not possible. Any help here is very welcome!

t.Parallel()

versionFixture := util.JoinPath(registryFixturePath, registryFixtureVersion)
helpers.CleanupTerragruntFolder(t, versionFixture)

_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --working-dir "+versionFixture)
require.NoError(t, err)
}
37 changes: 37 additions & 0 deletions tf/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,40 @@ type RegistryAPIErr struct {
func (err RegistryAPIErr) Error() string {
return fmt.Sprintf("Failed to fetch url %s: status code %d", err.url, err.statusCode)
}

// ModuleVersionsFetchErr is returned if we failed to fetch the module versions from the registry.
type ModuleVersionsFetchErr struct {
sourceURL string
}

func (err ModuleVersionsFetchErr) Error() string {
return fmt.Sprintf("Failed to fetch versions from %s. Please check authentication and registry is reachable", err.sourceURL)
}

// ModuleVersionConstraintErr is returned if the version constraint is not satisfied. This means there are no
// available versions for the module that satisfy the constraint.
type ModuleVersionConstraintErr struct {
versionConstraint string
}

func (err ModuleVersionConstraintErr) Error() string {
return fmt.Sprintf("Version constraint %s not satisfied", err.versionConstraint)
}

// ModuleVersionConstraintMalformedErr is returned if the version constraint is malformed and cannot be parsed.
type ModuleVersionConstraintMalformedErr struct {
versionConstraint string
}

func (err ModuleVersionConstraintMalformedErr) Error() string {
return fmt.Sprintf("Version constraint %s is malformed and cannot be parsed", err.versionConstraint)
}

// ModuleVersionMalformedErr is returned if the version string is malformed and cannot be parsed.
type ModuleVersionMalformedErr struct {
version string
}

func (err ModuleVersionMalformedErr) Error() string {
return fmt.Sprintf("Version %s is malformed and cannot be parsed", err.version)
}
121 changes: 119 additions & 2 deletions tf/getter.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import (
"os"
"path"
"path/filepath"
"sort"
"strings"

"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/pkg/log"
"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-getter"
safetemp "github.com/hashicorp/go-safetemp"
"github.com/hashicorp/go-version"

"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/util"
Expand All @@ -40,6 +42,21 @@ type RegistryServicePath struct {
ModulesPath string `json:"modules.v1"`
}

// Modules is a struct for extracting the modules list from a versions endpoint in the Registry.
type Modules struct {
Modules []Module `json:"modules"`
}

// Module is a struct for extracting the module versions from Modules.
type Module struct {
ModuleVersions []ModuleVersion `json:"versions"`
}

// ModuleVersion is a struct for extracting the module version from Module.
type ModuleVersion struct {
Version string `json:"version"`
}

// 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:
//
Expand Down Expand Up @@ -135,18 +152,30 @@ func (tfrGetter *RegistryGetter) Get(dstPath string, srcURL *url.URL) error {
return errors.New(MalformedRegistryURLErr{reason: "more than one version query"})
}

version := versionList[0]
versionQuery := versionList[0]

moduleRegistryBasePath, err := GetModuleRegistryURLBasePath(ctx, tfrGetter.TerragruntOptions.Logger, registryDomain)
if err != nil {
return err
}

moduleURL, err := BuildRequestURL(registryDomain, moduleRegistryBasePath, modulePath, version)
moduleVersionsURL, err := BuildModuleVersionsURL(registryDomain, moduleRegistryBasePath, modulePath)
if err != nil {
return err
}

targetVersion, err := GetTargetVersion(ctx, tfrGetter.TerragruntOptions.Logger, *moduleVersionsURL, versionQuery)
if err != nil {
return err
}

moduleURL, err := BuildRequestURL(registryDomain, moduleRegistryBasePath, modulePath, targetVersion)
if err != nil {
return err
}

tfrGetter.TerragruntOptions.Logger.Infof("Downloading module from %s", moduleURL.String())
Copy link
Member

Choose a reason for hiding this comment

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

I'm not entirely sure we need this log message at the Info level. It may produce multiple unused lines, so would it make more sense to set it to Debug instead?

Copy link
Author

Choose a reason for hiding this comment

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

I have doubts also. However, since there's another Info log related to this and it includes the query string, I considered useful to have now this second log line clarifying which version is finally being downloaded without having to go some levels up (way far from getter.go) to change that behaviour.

As an example:
image

That said, I believe this is an intermediate step. The next one should be to implement the version attribute and properly handle the version constraints at an upper level (adding the ability to invalidate tg cache when there's a new version even if the code hasn't changed, among other changes). Then, both log lines should be addressed and merged into an more accurate one. WDYT?

Copy link
Author

Choose a reason for hiding this comment

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

FYI: Since the scope of the PR has been changed to also support a new version attribute, and this will imply modifying way more than the tf getter, I believe this logs issue could be addressed also at the root, so this log line in getter won't make sense anymore


terraformGet, err := GetTerraformGetHeader(ctx, tfrGetter.TerragruntOptions.Logger, *moduleURL)
if err != nil {
return err
Expand Down Expand Up @@ -268,6 +297,39 @@ func GetModuleRegistryURLBasePath(ctx context.Context, logger log.Logger, domain
return respJSON.ModulesPath, nil
}

// GetTargetVersion retrieves the target version of the module based on the version constraint provided. This function
// will return the highest version that satisfies the version constraint. If no version satisfies the constraint, an
// error will be returned.
func GetTargetVersion(ctx context.Context, logger log.Logger, url url.URL, versionQuery string) (string, error) {
Copy link
Member

Choose a reason for hiding this comment

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

It appears that this function is only used within the tf package. Would it make sense to mark it as private?

Copy link
Author

Choose a reason for hiding this comment

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

I tried to make it private, but then I get an error on the getter_test :/

Copy link
Author

Choose a reason for hiding this comment

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

FYI: Since the scope of the PR has been changed to also support a new version attribute, I believe I'll use this function from other places. Let's wait until that refactor is done to evaluate again.

body, _, err := httpGETAndGetResponse(ctx, logger, url)
if err != nil {
return "", errors.New(ModuleVersionsFetchErr{sourceURL: url.String()})
}

var responseJSON Modules
if err := json.Unmarshal(body, &responseJSON); err != nil {
return "", errors.New(ModuleVersionsFetchErr{sourceURL: url.String()})
}

if len(responseJSON.Modules) == 0 || len(responseJSON.Modules[0].ModuleVersions) == 0 {
return "", errors.New(ModuleVersionsFetchErr{sourceURL: url.String()})
Copy link
Member

Choose a reason for hiding this comment

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

It appears that we're reusing the same ModuleVersionsFetchErr error for multiple scenarios. This could be confusing to users, as the same error is thrown when the GET request fails, when the JSON response cannot be parsed, or when no module versions are found.

Copy link
Author

@juan-vg juan-vg Mar 4, 2025

Choose a reason for hiding this comment

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

While I agree with you, many of those errors are a consequence of the same problem (network issue or lacking proper authentication). As an example, both JSON response parsing error and no ModuleVersions in the response are a consequence of authentication lack OR module not existing, and for certain registries you can't know in advance. As an example, this is a non existing org/module but there's still a partial answer, which is exactly the same as when the org/module exists but the auth is missing or wrong. On the other hand, TFE seems to better implement this for their private registry: at least they return an error. However, since the answers differ from one implementation to another, I consider it's useful to condense these errors into a more abstract one (FetchErr).

What I could do for sure is to create separate errors for the GET request and these other tricky cases, and try to refine as much as possible. Let me know if you have any ideas here, please.

}

// Filter the available module versions based on the version constraint to get the compatible versions
compatibleVersions, err := getCompatibleVersions(responseJSON.Modules[0].ModuleVersions, versionQuery)
if err != nil {
return "", err
}

// Get the highest version from the compatible versions
targetVersion := getHighestVersion(compatibleVersions)
if targetVersion == "" {
return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
}

return targetVersion, 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) {
Expand Down Expand Up @@ -361,6 +423,26 @@ func httpGETAndGetResponse(ctx context.Context, logger log.Logger, getURL url.UR
return bodyData, &resp.Header, errors.New(err)
}

// BuildModuleVersionsURL - create url to fetch module versions using moduleRegistryBasePath.
func BuildModuleVersionsURL(registryDomain string, moduleRegistryBasePath string, modulePath string) (*url.URL, error) {
moduleRegistryBasePath = strings.TrimSuffix(moduleRegistryBasePath, "/")
modulePath = strings.TrimSuffix(modulePath, "/")
modulePath = strings.TrimPrefix(modulePath, "/")

moduleVersionsPath := fmt.Sprintf("%s/%s/versions", moduleRegistryBasePath, modulePath)

moduleVersionsURL, err := url.Parse(moduleVersionsPath)
if err != nil {
return nil, err
}

if moduleVersionsURL.Scheme != "" {
return moduleVersionsURL, nil
}

return &url.URL{Scheme: "https", Host: registryDomain, Path: moduleVersionsPath}, nil
}

// 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, "/")
Expand All @@ -380,3 +462,38 @@ func BuildRequestURL(registryDomain string, moduleRegistryBasePath string, modul

return &url.URL{Scheme: "https", Host: registryDomain, Path: moduleFullPath}, nil
}

// getHighestVersion returns the highest version from the list of versions, or an empty string if the list is empty.
func getHighestVersion(availableVersions []*version.Version) string {
if len(availableVersions) == 0 {
return ""
}

sort.Sort(version.Collection(availableVersions))

return availableVersions[len(availableVersions)-1].String()
}

// getCompatibleVersions returns the list of versions within availableVersions that satisfy the version
// constraint defined by versionQuery.
func getCompatibleVersions(availableVersions []ModuleVersion, versionQuery string) ([]*version.Version, error) {
var compatibleVersions []*version.Version

versionConstraint, err := version.NewConstraint(versionQuery)
if err != nil {
return nil, errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
}

for _, availableVersion := range availableVersions {
availableVersionParsed, err := version.NewVersion(availableVersion.Version)
if err != nil {
return nil, errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
}

if versionConstraint.Check(availableVersionParsed) {
compatibleVersions = append(compatibleVersions, availableVersionParsed)
}
}

return compatibleVersions, nil
}
79 changes: 79 additions & 0 deletions tf/getter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,84 @@ func TestBuildRequestUrlRelativePath(t *testing.T) {
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())
}

func TestGetTargetVersion(t *testing.T) {
t.Parallel()
registryDomain := "registry.terraform.io"
moduleRegistryBasePath := "/v1/modules/"
tc := []struct {
name string
modulePath string
versionQuery string
expectedResult string
}{
{
name: "FixedVersion",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: "3.3.0",
expectedResult: "3.3.0",
},
{
name: "PessimisticPatchVersion",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: "~> 0.0.1",
expectedResult: "0.0.7",
},
{
name: "PessimisticMinorVersion",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: "~> 3.3",
expectedResult: "3.16.0",
},
{
name: "ComplexConstraint",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: ">= 3.3.0, <5.0.0,!= 4.24.1,!=4.24.0",
expectedResult: "4.23.0",
},
{
name: "InvalidConstraint",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: ">= 3.3.0 and >4.24.1",
expectedResult: "",
},
{
name: "UnsatisfiableConstraint",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: ">= 3.3.0, <3.2.0",
expectedResult: "",
},
{
name: "InvalidVersion",
modulePath: "/terraform-aws-modules/iam/aws",
versionQuery: "~> a.b.c",
expectedResult: "",
},
{
name: "NotExistingModule",
modulePath: "/terraform-not-existing-modules/not-existing-module",
versionQuery: "3.3.0",
expectedResult: "",
},
}

for _, tt := range tc {
tt := tt

t.Run(tt.name, func(t *testing.T) {
t.Parallel()

moduleVersionsURL, err := tf.BuildModuleVersionsURL(registryDomain, moduleRegistryBasePath, tt.modulePath)
require.NoError(t, err)

targetVersion, err := tf.GetTargetVersion(context.Background(), log.New(), *moduleVersionsURL, tt.versionQuery)
if tt.expectedResult == "" {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedResult, targetVersion)
}
})
}
}