Skip to content

Commit e3497e8

Browse files
authored
fix: Adjusting lockfile handling when modules define constraints (#4514)
* fix: Addressing lint findings * fix: Addressing test failures * fix: Addressing lint failures * fix: Fixing `TestParseProviderConstraintsWithEnvironmentOverride` test * fix: Dropping log levels * fix: Fixing `TestMockUpdateLockfile`
1 parent b7c73be commit e3497e8

13 files changed

Lines changed: 786 additions & 18 deletions

File tree

internal/providercache/provider_cache.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,40 @@ func (cache *ProviderCache) warmUpCache(
211211
return nil, err
212212
}
213213

214+
providerConstraints, err := getproviders.ParseProviderConstraints(opts, filepath.Dir(opts.TerragruntConfigPath))
215+
if err != nil {
216+
l.Debugf("Failed to parse provider constraints from %s: %v", filepath.Dir(opts.TerragruntConfigPath), err)
217+
218+
providerConstraints = make(getproviders.ProviderConstraints)
219+
}
220+
221+
for _, provider := range caches {
222+
if providerCache, ok := provider.(*services.ProviderCache); ok {
223+
providerAddr := provider.Address()
224+
if constraint, exists := providerConstraints[providerAddr]; exists {
225+
providerCache.Provider.OriginalConstraints = constraint
226+
l.Debugf("Applied constraint %s to provider %s", constraint, providerAddr)
227+
} else {
228+
l.Debugf("No constraint found for provider %s", providerAddr)
229+
}
230+
}
231+
}
232+
214233
err = getproviders.UpdateLockfile(ctx, opts.WorkingDir, caches)
234+
if err != nil {
235+
return nil, err
236+
}
237+
238+
// For upgrade scenarios where no providers were newly cached, we still need to update
239+
// the lock file if module constraints have changed. This only happens during upgrades.
240+
// Check if user passed -upgrade flag to terraform init
241+
isUpgrade := util.ListContainsElement(opts.TerraformCliArgs, "-upgrade")
242+
243+
if len(caches) == 0 && len(providerConstraints) > 0 && isUpgrade {
244+
l.Debugf("No new providers cached, but constraints exist. Updating lock file constraints for upgrade scenario.")
245+
246+
err = getproviders.UpdateLockfileConstraints(ctx, opts.WorkingDir, providerConstraints)
247+
}
215248

216249
return nil, err
217250
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
terraform {
2+
required_version = ">= 1.5.0"
3+
4+
required_providers {
5+
cloudflare = {
6+
source = "cloudflare/cloudflare"
7+
version = "~> 4.0"
8+
}
9+
time = {
10+
source = "hashicorp/time"
11+
version = ">= 0.10.0"
12+
}
13+
}
14+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
terraform {
2+
source = "."
3+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package test_test
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/gruntwork-io/terragrunt/test/helpers"
11+
"github.com/gruntwork-io/terragrunt/util"
12+
"github.com/hashicorp/hcl/v2"
13+
"github.com/hashicorp/hcl/v2/hclwrite"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
const (
19+
testFixtureProviderCacheConstraintIssue = "fixtures/provider-cache/constraint-issue"
20+
)
21+
22+
// TestTerragruntProviderCacheConstraintIssue tests that provider cache preserves
23+
// module constraints instead of pinning exact versions in .terraform.lock.hcl files.
24+
// Reproduces and validates the fix for GitHub issue #4512.
25+
//
26+
//nolint:paralleltest,tparallel
27+
func TestTerragruntProviderCacheConstraintIssue(t *testing.T) {
28+
t.Parallel()
29+
30+
helpers.CleanupTerraformFolder(t, testFixtureProviderCacheConstraintIssue)
31+
tmpEnvPath := helpers.CopyEnvironment(t, testFixtureProviderCacheConstraintIssue)
32+
rootPath := util.JoinPath(tmpEnvPath, testFixtureProviderCacheConstraintIssue)
33+
appPath := filepath.Join(rootPath, "app")
34+
35+
providerCacheDir := t.TempDir()
36+
37+
t.Run("initial_setup_preserves_module_constraints", func(t *testing.T) {
38+
helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt init --provider-cache --provider-cache-dir %s --log-level trace --non-interactive --working-dir %s", providerCacheDir, appPath))
39+
40+
constraintsValue := extractConstraintsFromLockFile(t, appPath, "cloudflare/cloudflare")
41+
42+
expectedConstraints := "~> 4.0"
43+
assert.Equal(t, expectedConstraints, constraintsValue, "Initial lock file should preserve module's required_providers constraints")
44+
})
45+
46+
t.Run("upgrade_updates_constraints_to_match_module", func(t *testing.T) {
47+
// Update the main.tf file to change cloudflare version constraint from "~> 4.0" to "~> 4.40"
48+
mainTfPath := filepath.Join(appPath, "main.tf")
49+
originalContent, err := os.ReadFile(mainTfPath)
50+
require.NoError(t, err)
51+
52+
// Replace the version constraint
53+
updatedContent := strings.ReplaceAll(string(originalContent), `version = "~> 4.0"`, `version = "~> 4.40"`)
54+
require.NotEqual(t, string(originalContent), updatedContent, "Content should be different after replacement")
55+
56+
err = os.WriteFile(mainTfPath, []byte(updatedContent), 0644)
57+
require.NoError(t, err)
58+
59+
lockFilePreInit, err := os.ReadFile(filepath.Join(appPath, ".terraform.lock.hcl"))
60+
require.NoError(t, err)
61+
62+
// Run terragrunt init and check that the lock file isn't updated
63+
helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt init --provider-cache --provider-cache-dir %s --log-level trace --non-interactive --working-dir %s", providerCacheDir, appPath))
64+
lockFilePostInit, err := os.ReadFile(filepath.Join(appPath, ".terraform.lock.hcl"))
65+
require.NoError(t, err)
66+
assert.Equal(t, string(lockFilePreInit), string(lockFilePostInit), "Lock file should not be updated")
67+
68+
// Run terragrunt init -upgrade to update the lock file
69+
helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt init -upgrade --provider-cache --provider-cache-dir %s --log-level trace --non-interactive --working-dir %s", providerCacheDir, appPath))
70+
71+
lockFilePostUpgrade, err := os.ReadFile(filepath.Join(appPath, ".terraform.lock.hcl"))
72+
require.NoError(t, err)
73+
assert.NotEqual(t, string(lockFilePostInit), string(lockFilePostUpgrade), "Lock file should be updated")
74+
75+
// Verify the lock file constraints are updated to match the module
76+
constraintsValue := extractConstraintsFromLockFile(t, appPath, "cloudflare/cloudflare")
77+
78+
expectedConstraints := "~> 4.40"
79+
assert.Equal(t, expectedConstraints, constraintsValue, "Constraints should be updated to match the module's required_providers")
80+
})
81+
82+
t.Run("fresh_start_uses_module_constraints", func(t *testing.T) {
83+
// Delete the lock file
84+
lockfilePath := filepath.Join(appPath, ".terraform.lock.hcl")
85+
err := os.Remove(lockfilePath)
86+
require.NoError(t, err)
87+
88+
// Also clean up .terraform directory to ensure fresh start
89+
terraformDir := filepath.Join(appPath, ".terraform")
90+
if util.FileExists(terraformDir) {
91+
err = os.RemoveAll(terraformDir)
92+
require.NoError(t, err)
93+
}
94+
95+
helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt init --provider-cache --provider-cache-dir %s --log-level trace --non-interactive --working-dir %s", providerCacheDir, appPath))
96+
97+
constraintsValue := extractConstraintsFromLockFile(t, appPath, "cloudflare/cloudflare")
98+
99+
expectedConstraints := "~> 4.40"
100+
assert.Equal(t, expectedConstraints, constraintsValue, "Fresh lock file should use module's required_providers constraints")
101+
})
102+
}
103+
104+
// Helper function to extract constraints value from lock file
105+
func extractConstraintsFromLockFile(t *testing.T, appPath string, providerName string) string {
106+
t.Helper()
107+
108+
lockfilePath := filepath.Join(appPath, ".terraform.lock.hcl")
109+
require.True(t, util.FileExists(lockfilePath), "Lock file should exist")
110+
111+
// Read and parse the lock file
112+
lockfileContent, err := os.ReadFile(lockfilePath)
113+
require.NoError(t, err)
114+
115+
lockfile, diags := hclwrite.ParseConfig(lockfileContent, lockfilePath, hcl.Pos{Line: 1, Column: 1})
116+
require.False(t, diags.HasErrors(), "Lock file should be valid HCL")
117+
118+
// Find the provider block (handle both short and full provider names)
119+
var providerBlock *hclwrite.Block
120+
if strings.Contains(providerName, "/") {
121+
// Full name like "cloudflare/cloudflare"
122+
providerBlock = lockfile.Body().FirstMatchingBlock("provider", []string{"registry.terraform.io/" + providerName})
123+
if providerBlock == nil {
124+
// Try OpenTofu registry as well
125+
providerBlock = lockfile.Body().FirstMatchingBlock("provider", []string{"registry.opentofu.org/" + providerName})
126+
}
127+
} else {
128+
// Short name - search for matching block
129+
for _, block := range lockfile.Body().Blocks() {
130+
if block.Type() == "provider" && len(block.Labels()) > 0 {
131+
if strings.Contains(block.Labels()[0], providerName) {
132+
providerBlock = block
133+
break
134+
}
135+
}
136+
}
137+
}
138+
require.NotNil(t, providerBlock, "Provider block should exist in lock file")
139+
140+
// Get the constraints attribute
141+
constraintsAttr := providerBlock.Body().GetAttribute("constraints")
142+
require.NotNil(t, constraintsAttr, "Constraints attribute should exist")
143+
144+
constraintsValue := strings.Trim(string(constraintsAttr.Expr().BuildTokens(nil).Bytes()), ` "`)
145+
return constraintsValue
146+
}

tf/cache/models/provider.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ type Provider struct {
108108
Version string
109109
OS string
110110
Arch string
111+
112+
// OriginalConstraints holds the version constraints from the module's required_providers block
113+
OriginalConstraints string
111114
}
112115

113116
func ParseProvider(str string) *Provider {
@@ -155,6 +158,10 @@ func (provider *Provider) Address() string {
155158
return path.Join(provider.RegistryName, provider.Namespace, provider.Name)
156159
}
157160

161+
func (provider *Provider) Constraints() string {
162+
return provider.OriginalConstraints
163+
}
164+
158165
// Match returns true if all defined provider properties are matched.
159166
func (provider *Provider) Match(target *Provider) bool {
160167
registryNameMatch := provider.RegistryName == "" || target.RegistryName == "" || provider.RegistryName == target.RegistryName

0 commit comments

Comments
 (0)