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

Conversation

juan-vg
Copy link

@juan-vg juan-vg commented Feb 24, 2025

Description

Fixes #1930
Fixes #2572

TODOs

Read the Gruntwork contribution guidelines.

  • Update the docs.
  • Run the relevant tests successfully, including pre-commit checks.
  • Ensure any 3rd party code adheres with our license policy or delete this line if its not applicable.
  • Include release notes. If this PR is backward incompatible, include a migration guide.

Release Notes (draft)

Added support for version constraints for Terraform Registry modules

Summary by CodeRabbit

  • New Features

    • Enhanced module version management with clearer error messages for various failure scenarios.
    • Improved module version retrieval process, ensuring accurate version selection and increased logging transparency during downloads.
    • New configuration file for Terragrunt to retrieve modules from the public Terraform registry.
    • Added a new test for Terraform registry version resolution to ensure correct functionality.
  • Documentation

    • Updated documentation for the VERSION argument in the terraform block to clarify acceptable formats.
  • Tests

    • Expanded test coverage for the GetTargetVersion function to handle various input scenarios.

Copy link

vercel bot commented Feb 24, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
terragrunt-docs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 4, 2025 8:25pm

Copy link
Contributor

coderabbitai bot commented Feb 24, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

The changes introduce several new error types in the tf/errors.go file to improve error handling for module version issues. In tf/getter.go, new data structures and functions are added to facilitate the extraction of module version information from a registry's versions endpoint. The Get method is updated to use the new GetTargetVersion function, which constructs the URL for fetching module versions and handles filtering and parsing. Documentation is enhanced to clarify the acceptable formats for the VERSION argument, and new tests are added to validate the functionality.

Changes

File(s) Change Summary
tf/errors.go Added four new error types (ModuleVersionsFetchErr, ModuleVersionConstraintErr, ModuleVersionConstraintMalformedErr, ModuleVersionMalformedErr) along with their Error() methods for module version handling.
tf/getter.go Introduced three new structs (Modules, Module, and ModuleVersion) for JSON deserialization. Added GetTargetVersion, getHighestVersion, and other functions to enhance version resolution logic and logging.
docs/_docs/04_reference/04-config-blocks-and-attributes.md Updated documentation for the VERSION argument in the terraform block to include details on acceptable formats (specific version number or version constraint).
test/fixtures/tfr/version/terragrunt.hcl Created a new Terragrunt configuration file that retrieves a module from the public Terraform registry with specified version constraints.
test/integration_registry_test.go Added a new constant registryFixtureVersion and a test function TestTerraformRegistryVersionResolution to validate version resolution in the Terraform registry context.
tf/getter_test.go Introduced a new test function TestGetTargetVersion to validate the behavior of tf.GetTargetVersion across various scenarios using a table-driven test structure.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant GTV as "GetTargetVersion()"
    participant HTTP as "HTTP Client"
    participant JSON as "JSON Parser"
    participant Filter as "getHighestVersion()"

    Caller->>GTV: Call with module info and versionQuery
    GTV->>HTTP: Build URL and perform HTTP request
    HTTP-->>GTV: Return JSON response
    GTV->>JSON: Parse JSON into Modules struct
    GTV->>Filter: Filter available versions by constraint
    Filter-->>GTV: Return highest version
    GTV-->>Caller: Return highest version or error
Loading

Suggested reviewers

  • levkohimins
  • denis256

Poem

In code we trust, errors now bloom,
With modules dancing in their room.
New paths, functions, logs to see,
A version's flow set error-free.
Cheers to clean code and joyful spree!
✨🚀


Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tf/getter.go (1)

200-208: Consider removing the unused logger parameter.

In getHighestVersion(logger log.Logger, ...), the logger isn’t used, which might confuse future readers. You can either utilize the logger for debug logs or drop it to keep the function signature clean.

-func getHighestVersion(logger log.Logger, availableVersions []*version.Version) string {
+func getHighestVersion(availableVersions []*version.Version) string {
     if len(availableVersions) == 0 {
         return ""
     }
     sort.Sort(version.Collection(availableVersions))
     return availableVersions[len(availableVersions)-1].String()
 }
🧰 Tools
🪛 golangci-lint (1.62.2)

201-201: getHighestVersion - logger is unused

(unparam)


205-205: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4a36f3f and e6f7cec.

📒 Files selected for processing (2)
  • tf/errors.go (1 hunks)
  • tf/getter.go (4 hunks)
🧰 Additional context used
🪛 golangci-lint (1.62.2)
tf/getter.go

171-171: expressions should not be cuddled with blocks

(wsl)


224-224: Error return value of json.Unmarshal is not checked

(errcheck)


201-201: getHighestVersion - logger is unused

(unparam)


233-233: only one cuddle assignment allowed before range statement

(wsl)


219-219: assignments should only be cuddled with other assignments

(wsl)


220-220: only one cuddle assignment allowed before if statement

(wsl)


223-223: declarations should never be cuddled

(wsl)


230-230: only one cuddle assignment allowed before if statement

(wsl)


238-238: if statements should only be cuddled with assignments

(wsl)


248-248: return statements should not be cuddled if block has more than two lines

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)

🔇 Additional comments (7)
tf/getter.go (3)

155-155: Ensure consistent error checks for version query.

Everything looks correct here, but be mindful that you've specifically disallowed multiple version queries while expecting exactly one. This is fine if it matches your design. Just keep an eye out for future expansions if you ever need to handle more than one version.


162-166: Straightforward invocation of GetTargetVersion.

This call seamlessly integrates with the rest of the flow. Good job making the code more modular by separating out version resolution into its own function.


167-167: Great use of structured logging.

You’re using Logger.Infof with the URL, which is handy for debugging. No concerns here.

Also applies to: 171-171

tf/errors.go (4)

44-49: Solid error reporting for module fetch failures.

This addition clearly identifies when module versions can’t be retrieved, making troubleshooting easier.


53-57: Useful for indicating unsatisfied constraints.

A crisp error message for constraints that don’t match any available version. Perfect for clarifying the mismatch.


63-71: Clear message for malformed version constraints.

Good practice to have a dedicated error for incorrectly formatted constraints. Makes debugging faster.


72-80: Handles malformed version strings gracefully.

Distinguishing a malformed version from other errors is a nice touch. This keeps user-facing messages precise.

tf/getter.go Outdated
Comment on lines 209 to 249
func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain string, moduleRegistryBasePath string, modulePath string, versionQuery string) (string, error) {
// Retrieve the available versions for the module
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 "", errors.New(err)
}
body, _, err := httpGETAndGetResponse(ctx, logger, *moduleVersionsURL)
if err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
var responseJSON Modules
json.Unmarshal(body, &responseJSON)
availableVersions := responseJSON.Modules[0].ModuleVersions

// Filter the available versions based on the version constraint
filteredVersions := []*version.Version{}
versionConstraint, err := version.NewConstraint(versionQuery)
if err != nil {
return "", errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
}
for _, availableVersion := range availableVersions {
availableVersionParsed, err := version.NewVersion(availableVersion.Version)
if err != nil {
return "", errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
}
if versionConstraint.Check(availableVersionParsed) {
filteredVersions = append(filteredVersions, availableVersionParsed)
}
}

// Get the highest version from the filtered versions
targetVersion := getHighestVersion(logger, filteredVersions)
if targetVersion == "" {
return "", errors.New(ModuleVersionConstraintErr{versionConstraint: targetVersion})
}
return targetVersion, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add error handling for json.Unmarshal and clarify the final constraint error.

  1. Unmarshal error check: Currently, the return value of json.Unmarshal(body, &responseJSON) isn’t checked (line 224). This can mask malformed JSON scenarios.
  2. Constraint error detail: In line 246, you’re returning errors.New(ModuleVersionConstraintErr{versionConstraint: targetVersion}) when targetVersion is empty. This might be more accurate if you used versionQuery so that the reported unsatisfied constraint matches the user input.

Apply this diff to add error handling and reference the original constraint in the error:

--- a/tf/getter.go
+++ b/tf/getter.go
@@ -223,7 +223,13 @@ func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain stri
 	var responseJSON Modules
-	json.Unmarshal(body, &responseJSON)
+	if err := json.Unmarshal(body, &responseJSON); err != nil {
+		return "", errors.New(errors.New(
+			fmt.Sprintf("Failed to parse response JSON for module %s: %v",
+				modulePath, err)))
+	}

 	availableVersions := responseJSON.Modules[0].ModuleVersions

@@ -243,7 +249,7 @@ func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain stri
 	targetVersion := getHighestVersion(logger, filteredVersions)
 	if targetVersion == "" {
-		return "", errors.New(ModuleVersionConstraintErr{versionConstraint: targetVersion})
+		return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
 	}

 	return targetVersion, nil

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 golangci-lint (1.62.2)

224-224: Error return value of json.Unmarshal is not checked

(errcheck)


233-233: only one cuddle assignment allowed before range statement

(wsl)


219-219: assignments should only be cuddled with other assignments

(wsl)


220-220: only one cuddle assignment allowed before if statement

(wsl)


223-223: declarations should never be cuddled

(wsl)


230-230: only one cuddle assignment allowed before if statement

(wsl)


238-238: if statements should only be cuddled with assignments

(wsl)


248-248: return statements should not be cuddled if block has more than two lines

(wsl)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
tf/getter.go (3)

230-243: Consider using early returns to improve readability.

The version filtering logic can be simplified using early returns:

 filteredVersions := []*version.Version{}
 versionConstraint, err := version.NewConstraint(versionQuery)
 if err != nil {
   return "", errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
 }
 for _, availableVersion := range availableVersions {
   availableVersionParsed, err := version.NewVersion(availableVersion.Version)
   if err != nil {
-    return "", errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
+    // Skip invalid versions instead of failing
+    continue
   }
   if versionConstraint.Check(availableVersionParsed) {
     filteredVersions = append(filteredVersions, availableVersionParsed)
   }
 }
🧰 Tools
🪛 golangci-lint (1.62.2)

235-235: only one cuddle assignment allowed before range statement

(wsl)


232-232: only one cuddle assignment allowed before if statement

(wsl)


240-240: if statements should only be cuddled with assignments

(wsl)


200-207: Enhance function documentation.

Let's add more context about the sorting behavior:

-// getHighestVersion returns the highest version from the list of versions, or an empty string if the list is empty.
+// getHighestVersion returns the highest semantic version from the list of versions using natural version sorting
+// (e.g., 2.0.0 is higher than 1.9.9). Returns an empty string if the list is empty.
 func getHighestVersion(availableVersions []*version.Version) string {
🧰 Tools
🪛 golangci-lint (1.62.2)

205-205: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)


171-171: Consider adding more detailed logging.

The current log only shows the download URL. Consider adding version information for better traceability:

-tfrGetter.TerragruntOptions.Logger.Infof("Downloading module from %s", moduleURL.String())
+tfrGetter.TerragruntOptions.Logger.Infof("Downloading module from %s (version constraint: %s, resolved version: %s)", moduleURL.String(), versionQuery, targetVersion)
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e6f7cec and b90c34c.

📒 Files selected for processing (1)
  • tf/getter.go (4 hunks)
🧰 Additional context used
🪛 golangci-lint (1.62.2)
tf/getter.go

224-224: only one cuddle assignment allowed before if statement

(wsl)


235-235: only one cuddle assignment allowed before range statement

(wsl)


250-250: return statements should not be cuddled if block has more than two lines

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)


219-219: assignments should only be cuddled with other assignments

(wsl)


223-223: declarations should never be cuddled

(wsl)


227-227: assignments should only be cuddled with other assignments

(wsl)


232-232: only one cuddle assignment allowed before if statement

(wsl)


240-240: if statements should only be cuddled with assignments

(wsl)

🔇 Additional comments (1)
tf/getter.go (1)

45-58: Clean and well-structured data models!

The new structs for handling registry responses are well-documented and follow Go best practices for JSON deserialization.

tf/getter.go Outdated
Comment on lines 209 to 251
func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain string, moduleRegistryBasePath string, modulePath string, versionQuery string) (string, error) {
// Retrieve the available versions for the module
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 "", errors.New(err)
}
bodyData, _, err := httpGETAndGetResponse(ctx, logger, *moduleVersionsURL)
if err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
var responseJSON Modules
if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
availableVersions := responseJSON.Modules[0].ModuleVersions

// Filter the available versions based on the version constraint
filteredVersions := []*version.Version{}
versionConstraint, err := version.NewConstraint(versionQuery)
if err != nil {
return "", errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
}
for _, availableVersion := range availableVersions {
availableVersionParsed, err := version.NewVersion(availableVersion.Version)
if err != nil {
return "", errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
}
if versionConstraint.Check(availableVersionParsed) {
filteredVersions = append(filteredVersions, availableVersionParsed)
}
}

// Get the highest version from the filtered versions
targetVersion := getHighestVersion(filteredVersions)
if targetVersion == "" {
return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
}
return targetVersion, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add validation for empty response handling.

The function could panic if the registry returns an empty modules list. Let's add some guards:

 var responseJSON Modules
 if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
   return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
 }
+if len(responseJSON.Modules) == 0 {
+  return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
+}
 availableVersions := responseJSON.Modules[0].ModuleVersions
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain string, moduleRegistryBasePath string, modulePath string, versionQuery string) (string, error) {
// Retrieve the available versions for the module
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 "", errors.New(err)
}
bodyData, _, err := httpGETAndGetResponse(ctx, logger, *moduleVersionsURL)
if err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
var responseJSON Modules
if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
availableVersions := responseJSON.Modules[0].ModuleVersions
// Filter the available versions based on the version constraint
filteredVersions := []*version.Version{}
versionConstraint, err := version.NewConstraint(versionQuery)
if err != nil {
return "", errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
}
for _, availableVersion := range availableVersions {
availableVersionParsed, err := version.NewVersion(availableVersion.Version)
if err != nil {
return "", errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
}
if versionConstraint.Check(availableVersionParsed) {
filteredVersions = append(filteredVersions, availableVersionParsed)
}
}
// Get the highest version from the filtered versions
targetVersion := getHighestVersion(filteredVersions)
if targetVersion == "" {
return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
}
return targetVersion, nil
}
func GetTargetVersion(ctx context.Context, logger log.Logger, registryDomain string, moduleRegistryBasePath string, modulePath string, versionQuery string) (string, error) {
// Retrieve the available versions for the module
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 "", errors.New(err)
}
bodyData, _, err := httpGETAndGetResponse(ctx, logger, *moduleVersionsURL)
if err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
var responseJSON Modules
if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
if len(responseJSON.Modules) == 0 {
return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
}
availableVersions := responseJSON.Modules[0].ModuleVersions
// Filter the available versions based on the version constraint
filteredVersions := []*version.Version{}
versionConstraint, err := version.NewConstraint(versionQuery)
if err != nil {
return "", errors.New(ModuleVersionConstraintMalformedErr{versionConstraint: versionQuery})
}
for _, availableVersion := range availableVersions {
availableVersionParsed, err := version.NewVersion(availableVersion.Version)
if err != nil {
return "", errors.New(ModuleVersionMalformedErr{version: availableVersion.Version})
}
if versionConstraint.Check(availableVersionParsed) {
filteredVersions = append(filteredVersions, availableVersionParsed)
}
}
// Get the highest version from the filtered versions
targetVersion := getHighestVersion(filteredVersions)
if targetVersion == "" {
return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
}
return targetVersion, nil
}
🧰 Tools
🪛 golangci-lint (1.62.2)

224-224: only one cuddle assignment allowed before if statement

(wsl)


235-235: only one cuddle assignment allowed before range statement

(wsl)


250-250: return statements should not be cuddled if block has more than two lines

(wsl)


219-219: assignments should only be cuddled with other assignments

(wsl)


223-223: declarations should never be cuddled

(wsl)


227-227: assignments should only be cuddled with other assignments

(wsl)


232-232: only one cuddle assignment allowed before if statement

(wsl)


240-240: if statements should only be cuddled with assignments

(wsl)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
tf/getter.go (2)

209-254: Consider enhancing error messages for better debugging.

The error handling is solid, but we could make the error messages more helpful:

 if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
-    return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
+    return "", errors.New(ModuleVersionsErr{
+        moduleName: modulePath,
+        details:    fmt.Sprintf("failed to parse JSON response: %v", err),
+    })
 }
🧰 Tools
🪛 golangci-lint (1.62.2)

216-216: only one cuddle assignment allowed before if statement

(wsl)


227-227: if statements should only be cuddled with assignments

(wsl)


243-243: if statements should only be cuddled with assignments

(wsl)


238-238: only one cuddle assignment allowed before range statement

(wsl)


223-223: declarations should never be cuddled

(wsl)


224-224: only one cuddle assignment allowed before if statement

(wsl)


235-235: only one cuddle assignment allowed before if statement

(wsl)


167-172: Consider standardizing the log message format.

The logging addition is helpful, but let's make it consistent with other log messages in the codebase. Consider using a more structured format:

-tfrGetter.TerragruntOptions.Logger.Infof("Downloading module from %s", moduleURL.String())
+tfrGetter.TerragruntOptions.Logger.Infof("Downloading Terraform module from registry: %s", moduleURL.String())
🧰 Tools
🪛 golangci-lint (1.62.2)

171-171: expressions should not be cuddled with blocks

(wsl)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b90c34c and 43731b8.

📒 Files selected for processing (2)
  • docs/_docs/04_reference/04-config-blocks-and-attributes.md (1 hunks)
  • tf/getter.go (4 hunks)
🧰 Additional context used
🪛 golangci-lint (1.62.2)
tf/getter.go

171-171: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)


216-216: only one cuddle assignment allowed before if statement

(wsl)


227-227: if statements should only be cuddled with assignments

(wsl)


243-243: if statements should only be cuddled with assignments

(wsl)


238-238: only one cuddle assignment allowed before range statement

(wsl)


223-223: declarations should never be cuddled

(wsl)


224-224: only one cuddle assignment allowed before if statement

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)


235-235: only one cuddle assignment allowed before if statement

(wsl)

🔇 Additional comments (3)
docs/_docs/04_reference/04-config-blocks-and-attributes.md (1)

99-100: Great documentation addition!

The explanation of version constraints is clear and the reference to official Terraform documentation is helpful.

tf/getter.go (2)

45-58: Clean struct definitions!

The structs are well-documented and follow Go conventions for JSON handling. The nested structure matches the expected JSON response format from the registry.


201-207: Nice implementation of version selection!

The function handles edge cases well and makes good use of the version.Collection sort interface.

🧰 Tools
🪛 golangci-lint (1.62.2)

206-206: return statements should not be cuddled if block has more than two lines

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)

@yhakbar yhakbar marked this pull request as draft February 24, 2025 18:28
@juan-vg juan-vg changed the title [WIP] Support version constraints for Terraform Registry modules Support version constraints for Terraform Registry modules Feb 25, 2025
@juan-vg juan-vg marked this pull request as ready for review February 25, 2025 16:02
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (5)
test/integration_registry_test.go (1)

61-69: Consider enhancing this test to verify the resolved version

The test successfully verifies that initialization works with version constraints, but doesn't validate that the correct version was actually selected.

Consider expanding the test to verify the specific version that was selected. Example approach:

func TestTerraformRegistryVersionResolution(t *testing.T) {
	t.Parallel()

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

-	_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --working-dir "+versionFixture)
+	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --working-dir "+versionFixture)
	require.NoError(t, err)
+
+	// Verify that the output contains a version that matches our constraints
+	versionPattern := regexp.MustCompile(`Installing terraform-aws-modules/iam/aws v(5\.[0-4][0-9]\.[0-9]+|5\.5[0-9]\.[0-9]+)`)
+	assert.True(t, versionPattern.MatchString(stdout), "Expected output to show installation of a version matching the constraints")
}
tf/getter.go (4)

200-207: Consider adding a test for empty version list handling

The getHighestVersion function correctly handles the case of an empty version list, but the current tests don't specifically test this edge case.

Add a test case like:

func TestGetHighestVersionEmptyList(t *testing.T) {
	result := getHighestVersion([]*version.Version{})
	assert.Equal(t, "", result, "Expected empty string for empty version list")
}
🧰 Tools
🪛 golangci-lint (1.62.2)

206-206: return statements should not be cuddled if block has more than two lines

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)


212-262: Improve documentation for better maintainability

The function implementation is solid, but could benefit from more detailed documentation.

Consider enhancing the function documentation with:

  1. Example usage
  2. Expected error types that callers should handle
  3. More context about the expected module path format
// 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.
+//
+// Example:
+//
+//   version, err := GetTargetVersion(ctx, logger, "registry.terraform.io", "/v1/modules/", 
+//                                  "/terraform-aws-modules/vpc/aws", "~> 3.0")
+//
+// The function will return errors in these cases:
+// - ModuleVersionsErr: If the module or its versions cannot be retrieved
+// - ModuleVersionConstraintMalformedErr: If the version constraint syntax is invalid
+// - ModuleVersionMalformedErr: If a version in the registry response is invalid
+// - ModuleVersionConstraintErr: If no version satisfies the constraint
🧰 Tools
🪛 golangci-lint (1.62.2)

231-231: declarations should never be cuddled

(wsl)


246-246: only one cuddle assignment allowed before range statement

(wsl)


232-232: only one cuddle assignment allowed before if statement

(wsl)


261-261: return statements should not be cuddled if block has more than two lines

(wsl)


235-235: if statements should only be cuddled with assignments

(wsl)


238-238: assignments should only be cuddled with other assignments

(wsl)


251-251: if statements should only be cuddled with assignments

(wsl)


235-237: Add more specific error messages for missing modules

The current error doesn't distinguish between "module not found" and "module has no versions".

Consider more specific error messages:

  if len(responseJSON.Modules) == 0 || len(responseJSON.Modules[0].ModuleVersions) == 0 {
-    return "", errors.New(ModuleVersionsErr{moduleName: modulePath})
+    if len(responseJSON.Modules) == 0 {
+      return "", errors.New(ModuleVersionsErr{moduleName: modulePath, details: "module not found"})
+    }
+    return "", errors.New(ModuleVersionsErr{moduleName: modulePath, details: "module has no published versions"})
  }
🧰 Tools
🪛 golangci-lint (1.62.2)

235-235: if statements should only be cuddled with assignments

(wsl)


257-260: Consider logging the selected version for traceability

Adding logging here would help users understand which version was selected based on their constraints.

  targetVersion := getHighestVersion(filteredVersions)
  if targetVersion == "" {
    return "", errors.New(ModuleVersionConstraintErr{versionConstraint: versionQuery})
  }
+ logger.Infof("Selected version %s for module %s based on constraint %s", 
+              targetVersion, modulePath, versionQuery)
  return targetVersion, nil
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 43731b8 and 9e2b8cb.

📒 Files selected for processing (4)
  • test/fixtures/tfr/version/terragrunt.hcl (1 hunks)
  • test/integration_registry_test.go (2 hunks)
  • tf/getter.go (4 hunks)
  • tf/getter_test.go (1 hunks)
🧰 Additional context used
🪛 golangci-lint (1.62.2)
tf/getter.go

171-171: expressions should not be cuddled with blocks

(wsl)


206-206: return statements should not be cuddled if block has more than two lines

(wsl)


231-231: declarations should never be cuddled

(wsl)


246-246: only one cuddle assignment allowed before range statement

(wsl)


232-232: only one cuddle assignment allowed before if statement

(wsl)


261-261: return statements should not be cuddled if block has more than two lines

(wsl)


235-235: if statements should only be cuddled with assignments

(wsl)


238-238: assignments should only be cuddled with other assignments

(wsl)


251-251: if statements should only be cuddled with assignments

(wsl)


205-205: expressions should not be cuddled with blocks

(wsl)

🔇 Additional comments (5)
test/fixtures/tfr/version/terragrunt.hcl (1)

1-8: Looks well-structured for testing version constraints!

This test fixture configures a typical Terraform registry module with complex version constraints. The pattern ~>5.0, <5.51.0, !=5.50.0 demonstrates multiple constraint types:

  • Pessimistic version operator (~>5.0) - any 5.x version
  • Upper bound (<5.51.0) - versions below 5.51.0
  • Exclusion (!=5.50.0) - specifically avoiding 5.50.0

This will be valuable for testing the version resolution logic.

test/integration_registry_test.go (1)

20-20: Good addition of the version fixture constant.

The constant naming is consistent with the existing fixture path constants.

tf/getter_test.go (1)

158-233: Great comprehensive test coverage for the version resolver!

This table-driven test thoroughly covers the various version constraint scenarios:

  • Fixed versions
  • Pessimistic versioning (~>)
  • Complex constraints with multiple conditions
  • Error cases (invalid constraints, non-existent modules, etc.)

The tests provide a clear contract for how the version resolver should behave in different situations, which will make maintenance easier.

tf/getter.go (2)

46-58: Well-structured JSON mapping types

These struct definitions provide a clean mapping for the registry API response. The naming is clear and follows Go conventions.


162-172: Good improvement to use version resolution and add logging

Replacing the direct version query with the GetTargetVersion function improves the module handling. The added logging will also make debugging easier by showing which URL is being used for downloads.

🧰 Tools
🪛 golangci-lint (1.62.2)

171-171: expressions should not be cuddled with blocks

(wsl)

@juan-vg juan-vg changed the title Support version constraints for Terraform Registry modules feat: Support version constraints for Terraform Registry modules Feb 27, 2025
Copy link
Collaborator

@yhakbar yhakbar left a comment

Choose a reason for hiding this comment

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

@juan-vg

Our linter found some issues in CI:

tf/getter.go:251:3: if statements should only be cuddled with assignments (wsl)
                if versionConstraint.Check(availableVersionParsed) {
                ^
tf/getter.go:231:2: declarations should never be cuddled (wsl)
        var responseJSON Modules
        ^
tf/getter.go:235:2: if statements should only be cuddled with assignments (wsl)
        if len(responseJSON.Modules) == 0 || len(responseJSON.Modules[0].ModuleVersions) == 0 {
        ^
tf/getter.go:243:2: only one cuddle assignment allowed before if statement (wsl)
        if err != nil {
        ^
tf/getter.go:206:2: return statements should not be cuddled if block has more than two lines (wsl)
        return availableVersions[len(availableVersions)-1].String()
        ^
tf/getter.go:227:2: assignments should only be cuddled with other assignments (wsl)
        bodyData, _, err := httpGETAndGetResponse(ctx, logger, *moduleVersionsURL)
        ^
tf/getter.go:232:2: only one cuddle assignment allowed before if statement (wsl)
        if err := json.Unmarshal(bodyData, &responseJSON); err != nil {
        ^
tf/getter.go:171:2: expressions should not be cuddled with blocks (wsl)
        tfrGetter.TerragruntOptions.Logger.Infof("Downloading module from %s", moduleURL.String())
        ^
tf/getter.go:246:2: only one cuddle assignment allowed before range statement (wsl)
        for _, availableVersion := range availableVersions {
        ^
tf/getter.go:261:2: return statements should not be cuddled if block has more than two lines (wsl)
        return targetVersion, nil
        ^
tf/getter.go:238:2: assignments should only be cuddled with other assignments (wsl)
        availableVersions := responseJSON.Modules[0].ModuleVersions
        ^
tf/getter.go:205:2: expressions should not be cuddled with blocks (wsl)
        sort.Sort(version.Collection(availableVersions))
        ^
tf/getter.go:224:2: only one cuddle assignment allowed before if statement (wsl)
        if err != nil {
        ^
tf/getter.go:228:2: only one cuddle assignment allowed before if statement (wsl)
        if err != nil {
        ^
INFO File cache stats: 403 entries of total size 2.3MiB 
INFO Memory: 2474 samples, avg is 2695.3MB, max is 3747.3MB 
INFO Execution took 4m11.814409778s               
make: *** [Makefile:45: run-lint] Error 1

Exited with code exit status 2

Please request re-review when you've addressed these issues.

@juan-vg juan-vg changed the title feat: Support version constraints for Terraform Registry modules [WIP] feat: Support version constraints for Terraform Registry modules Feb 28, 2025
@juan-vg juan-vg marked this pull request as draft February 28, 2025 10:32
@juan-vg
Copy link
Author

juan-vg commented Mar 3, 2025

Shame on me I forgot to run the linter... I'm on it!

@juan-vg juan-vg marked this pull request as ready for review March 3, 2025 12:56
@juan-vg juan-vg changed the title [WIP] feat: Support version constraints for Terraform Registry modules feat: Support version constraints for Terraform Registry modules Mar 3, 2025
@juan-vg juan-vg requested a review from yhakbar March 3, 2025 13:14
@yhakbar
Copy link
Collaborator

yhakbar commented Mar 3, 2025

Thanks for addressing those, @juan-vg .

Upon re-review, I realized that the implementation you've added uses a query string parameter for the source URL:

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}"
}

Instead of a new version attribute:

terraform {
  source  = "tfr:///terraform-aws-modules/iam/aws"
  version = "~>5.0, <5.51.0, !=5.50.0"
}

I believe a new version attribute would be more in-line with the UI/UX of OpenTofu/Terraform, so users may find it more convenient to follow that pattern as well. What do you think? Would you be willing to try out an implementation that uses a version attribute?

@@ -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!

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

// 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.

}

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.

@juan-vg
Copy link
Author

juan-vg commented Mar 4, 2025

@yhakbar

I believe a new version attribute would be more in-line with the UI/UX of OpenTofu/Terraform, so users may find it more convenient to follow that pattern as well. What do you think? Would you be willing to try out an implementation that uses a version attribute?

While I agree the new version attribute completely makes sense (and I'm willing to try that implementation), I'm wondering if it belongs to the scope of this PR since the version attribute is already there via query string. I mean, adding that attribute implies a (probably full) refactor of this code and at the end the target is to add the new attribute and not to support version constraints (even if they're related). From my POV the version attribute should be added as an incremental, later change after supporting version constraints. However, that's my take. WDYT?

@juan-vg juan-vg marked this pull request as draft March 4, 2025 20:24
@juan-vg juan-vg changed the title feat: Support version constraints for Terraform Registry modules [WIP] feat: Support version constraints for Terraform Registry modules Mar 4, 2025
@yhakbar
Copy link
Collaborator

yhakbar commented Mar 5, 2025

While I agree the new version attribute completely makes sense (and I'm willing to try that implementation), I'm wondering if it belongs to the scope of this PR since the version attribute is already there via query string. ... However, that's my take. WDYT?

@juan-vg

Normally, I'd be totally onboard with an incremental improvement rather than hold up a PR, but in the case of TG configuration, we want to be more conservative. This introduces configuration for a URL that is not URL encoded, and when we support the attribute, users should switch off using the query string parameter, which would be a breaking change.

If you really don't have time to rework with PR to use an attribute, I'd be open to reviewing it after the changes have been gated behind an experiment flag. That way, we can document the feature as incomplete, and require user opt in to use behavior that might break in the future.

@juan-vg
Copy link
Author

juan-vg commented Mar 6, 2025

@yhakbar

Normally, I'd be totally onboard with an incremental improvement rather than hold up a PR, but in the case of TG configuration, we want to be more conservative. This introduces configuration for a URL that is not URL encoded, and when we support the attribute, users should switch off using the query string parameter, which would be a breaking change.

I understand

If you really don't have time to rework with PR to use an attribute, I'd be open to reviewing it after the changes have been gated behind an experiment flag. That way, we can document the feature as incomplete, and require user opt in to use behavior that might break in the future.

Nah, let's do it! I have some time and much willing, and any guiding or help will be appreciated :)

@stephenbawks
Copy link

Bumping this to see if there were any more updates. We would really like to see this adopted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants