Skip to content

Commit fdd2ec2

Browse files
committed
feat: Generate full lock file expected in OpenTofu 1.12
1 parent 818aa44 commit fdd2ec2

12 files changed

Lines changed: 389 additions & 36 deletions

File tree

internal/tf/cache/models/provider.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,19 +101,26 @@ func (platform Platform) String() string {
101101
return fmt.Sprintf("%s/%s", platform.OS, platform.Arch)
102102
}
103103

104-
// ResponseBody represents the details of the Terraform provider received from a registry.
104+
// PlatformPackage carries the per-platform hash and size data exposed by the
105+
// OpenTofu provider registry. Consumers can populate `.terraform.lock.hcl`
106+
// with hashes for every supported platform from a single response, without
107+
// downloading each provider archive.
108+
type PlatformPackage struct {
109+
Hashes []string `json:"hashes"`
110+
PackageSize int64 `json:"package_size,omitempty"`
111+
}
112+
113+
// ResponseBody represents the details of the OpenTofu/Terraform provider received from a registry.
105114
type ResponseBody struct {
115+
Packages map[string]*PlatformPackage `json:"packages,omitempty"`
106116
Platform
107-
108-
Protocols []string `json:"protocols,omitempty"`
109-
Filename string `json:"filename"`
110-
111-
DownloadURL string `json:"download_url"`
112-
SHA256SumsURL string `json:"shasums_url,omitempty"`
113-
SHA256SumsSignatureURL string `json:"shasums_signature_url,omitempty"`
114-
115-
SHA256Sum string `json:"shasum,omitempty"`
116-
SigningKeys SigningKeyList `json:"signing_keys"`
117+
Filename string `json:"filename"`
118+
DownloadURL string `json:"download_url"`
119+
SHA256SumsURL string `json:"shasums_url,omitempty"`
120+
SHA256SumsSignatureURL string `json:"shasums_signature_url,omitempty"`
121+
SHA256Sum string `json:"shasum,omitempty"`
122+
Protocols []string `json:"protocols,omitempty"`
123+
SigningKeys SigningKeyList `json:"signing_keys"`
117124
}
118125

119126
func (body *ResponseBody) ResolveRelativeReferences(base *url.URL) *ResponseBody {

internal/tf/cache/models/provider_test.go

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models_test
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/url"
67
"testing"
@@ -10,6 +11,54 @@ import (
1011
"github.com/stretchr/testify/require"
1112
)
1213

14+
func TestResponseBodyPackagesRoundTrip(t *testing.T) {
15+
t.Parallel()
16+
17+
// Sample response shape extracted from opentofu/opentofu RFC
18+
// 20251027-provider-registry-hashes.md, including the `packages` field that
19+
// carries per-platform hashes.
20+
raw := []byte(`{
21+
"protocols": ["5.0"],
22+
"os": "linux",
23+
"arch": "amd64",
24+
"filename": "terraform-provider-aws_5.31.0_linux_amd64.zip",
25+
"download_url": "https://example.com/terraform-provider-aws_5.31.0_linux_amd64.zip",
26+
"shasums_url": "https://example.com/SHA256SUMS",
27+
"shasum": "5f9c7aa76b7c34d722fc91111111111111111111111111111111111111111111",
28+
"signing_keys": {"gpg_public_keys": []},
29+
"packages": {
30+
"linux_amd64": {
31+
"hashes": ["zh:abc", "h1:def"],
32+
"package_size": 12345
33+
},
34+
"darwin_arm64": {
35+
"hashes": ["zh:ghi", "h1:jkl"],
36+
"package_size": 67890
37+
}
38+
}
39+
}`)
40+
41+
var body models.ResponseBody
42+
require.NoError(t, json.Unmarshal(raw, &body))
43+
44+
require.Len(t, body.Packages, 2)
45+
require.Contains(t, body.Packages, "linux_amd64")
46+
assert.Equal(t, []string{"zh:abc", "h1:def"}, body.Packages["linux_amd64"].Hashes)
47+
assert.Equal(t, int64(12345), body.Packages["linux_amd64"].PackageSize)
48+
assert.Equal(t, []string{"zh:ghi", "h1:jkl"}, body.Packages["darwin_arm64"].Hashes)
49+
}
50+
51+
func TestResponseBodyPackagesAbsent(t *testing.T) {
52+
t.Parallel()
53+
54+
raw := []byte(`{"os":"linux","arch":"amd64","filename":"x.zip","download_url":"https://example.com/x.zip","signing_keys":{"gpg_public_keys":[]}}`)
55+
56+
var body models.ResponseBody
57+
require.NoError(t, json.Unmarshal(raw, &body))
58+
59+
assert.Nil(t, body.Packages)
60+
}
61+
1362
func TestFilterValid(t *testing.T) {
1463
t.Parallel()
1564

@@ -112,39 +161,39 @@ func TestResolveRelativeReferences(t *testing.T) {
112161
expectedResolved models.ResponseBody
113162
}{
114163
{
115-
"https://releases.hashicorp.com/terraform-provider-local/2.5.1",
116-
models.ResponseBody{
164+
baseURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1",
165+
body: models.ResponseBody{
117166
DownloadURL: "terraform-provider-local_2.5.1_darwin_amd64.zip",
118167
SHA256SumsURL: "terraform-provider-local_2.5.1_SHA256SUMS",
119168
SHA256SumsSignatureURL: "terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig",
120169
},
121-
models.ResponseBody{
170+
expectedResolved: models.ResponseBody{
122171
DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip",
123172
SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS",
124173
SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig",
125174
},
126175
},
127176
{
128-
"https://somehost.com",
129-
models.ResponseBody{
177+
baseURL: "https://somehost.com",
178+
body: models.ResponseBody{
130179
DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip",
131180
SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS",
132181
SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig",
133182
},
134-
models.ResponseBody{
183+
expectedResolved: models.ResponseBody{
135184
DownloadURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_darwin_amd64.zip",
136185
SHA256SumsURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS",
137186
SHA256SumsSignatureURL: "https://releases.hashicorp.com/terraform-provider-local/2.5.1/terraform-provider-local_2.5.1_SHA256SUMS.72D7468F.sig",
138187
},
139188
},
140189
{
141-
"https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64",
142-
models.ResponseBody{
190+
baseURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64",
191+
body: models.ResponseBody{
143192
DownloadURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider.zip",
144193
SHA256SumsURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS",
145194
SHA256SumsSignatureURL: "/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS.sig",
146195
},
147-
models.ResponseBody{
196+
expectedResolved: models.ResponseBody{
148197
DownloadURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider.zip",
149198
SHA256SumsURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS",
150199
SHA256SumsSignatureURL: "https://registry.company.com/v1/providers/ns/name/1.0/download/linux/amd64/terraform-provider_SHA256SUMS.sig",

internal/tf/cache/services/provider_cache.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,33 @@ func (cache *ProviderCache) PackageDir() string {
130130
return cache.packageDir
131131
}
132132

133+
// RegistryHashes returns the per-platform hashes supplied by the upstream registry.
134+
// Returns nil when the registry response did not include the
135+
// `packages` field, in which case lockfile generation falls back to local h1
136+
// computation plus the shasums document.
137+
func (cache *ProviderCache) RegistryHashes() map[string][]getproviders.Hash {
138+
if cache.ResponseBody == nil || len(cache.Packages) == 0 {
139+
return nil
140+
}
141+
142+
out := make(map[string][]getproviders.Hash, len(cache.Packages))
143+
144+
for platform, pkg := range cache.Packages {
145+
if pkg == nil {
146+
continue
147+
}
148+
149+
hashes := make([]getproviders.Hash, 0, len(pkg.Hashes))
150+
for _, h := range pkg.Hashes {
151+
hashes = append(hashes, getproviders.Hash(h))
152+
}
153+
154+
out[platform] = hashes
155+
}
156+
157+
return out
158+
}
159+
133160
func (cache *ProviderCache) AuthenticatePackage(ctx context.Context) (*getproviders.PackageAuthenticationResult, error) {
134161
var (
135162
checksum [sha256.Size]byte

internal/tf/getproviders/lock.go

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,27 +105,15 @@ func updateProviderBlock(ctx context.Context, providerBlock *hclwrite.Block, pro
105105
providerBlock.Body().SetAttributeValue("constraints", cty.StringVal(constraintsValue))
106106
}
107107

108-
h1Hash, err := PackageHashV1(provider.PackageDir())
109-
if err != nil {
110-
return err
111-
}
112-
113-
newHashes := []Hash{h1Hash}
114-
115-
documentSHA256Sums, err := provider.DocumentSHA256Sums(ctx)
108+
newHashes, err := collectNewHashes(ctx, provider)
116109
if err != nil {
117110
return err
118111
}
119112

120-
if documentSHA256Sums != nil {
121-
zipHashes := DocumentHashes(documentSHA256Sums)
122-
newHashes = append(newHashes, zipHashes...)
123-
}
124-
125113
// merge with existing hashes
126-
for _, newHashe := range newHashes {
127-
if !slices.Contains(hashes, newHashe) {
128-
hashes = append(hashes, newHashe)
114+
for _, newHash := range newHashes {
115+
if !slices.Contains(hashes, newHash) {
116+
hashes = append(hashes, newHash)
129117
}
130118
}
131119

@@ -136,6 +124,62 @@ func updateProviderBlock(ctx context.Context, providerBlock *hclwrite.Block, pro
136124
return nil
137125
}
138126

127+
// collectNewHashes returns the hashes to merge into the lock file for the given
128+
// provider, preferring registry-supplied hashes when available and otherwise
129+
// falling back to computing them from the cached package and shasums document.
130+
func collectNewHashes(ctx context.Context, provider Provider) ([]Hash, error) {
131+
if registryHashes := provider.RegistryHashes(); len(registryHashes) > 0 {
132+
return uniqueRegistryHashes(registryHashes), nil
133+
}
134+
135+
return computedFallbackHashes(ctx, provider)
136+
}
137+
138+
// uniqueRegistryHashes flattens the per-platform hashes from the OpenTofu
139+
// registry's `packages` response field into a deduplicated list. Each input
140+
// hash is already scheme-prefixed (e.g. `h1:` or `zh:`).
141+
func uniqueRegistryHashes(byPlatform map[string][]Hash) []Hash {
142+
seen := make(map[Hash]struct{})
143+
144+
var hashes []Hash
145+
146+
for _, platformHashes := range byPlatform {
147+
for _, h := range platformHashes {
148+
if _, ok := seen[h]; ok {
149+
continue
150+
}
151+
152+
seen[h] = struct{}{}
153+
hashes = append(hashes, h)
154+
}
155+
}
156+
157+
return hashes
158+
}
159+
160+
// computedFallbackHashes returns the legacy hash set for a provider: an `h1:`
161+
// hash derived from the unpacked package on disk, followed by `zh:` hashes
162+
// parsed from the registry's shasums document.
163+
func computedFallbackHashes(ctx context.Context, provider Provider) ([]Hash, error) {
164+
h1Hash, err := PackageHashV1(provider.PackageDir())
165+
if err != nil {
166+
return nil, err
167+
}
168+
169+
hashes := []Hash{h1Hash}
170+
171+
documentSHA256Sums, err := provider.DocumentSHA256Sums(ctx)
172+
if err != nil {
173+
return nil, err
174+
}
175+
176+
if documentSHA256Sums != nil {
177+
hashes = append(hashes, DocumentHashes(documentSHA256Sums)...)
178+
}
179+
180+
return hashes, nil
181+
}
182+
139183
func getExistingHashes(providerBlock *hclwrite.Block, provider Provider) ([]Hash, error) {
140184
versionAttr := providerBlock.Body().GetAttribute("version")
141185
if versionAttr == nil {

internal/tf/getproviders/lock_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func mockProviderWithConstraints(t *testing.T, ctrl *gomock.Controller, address,
5454
provider.EXPECT().PackageDir().Return(packageDir).AnyTimes()
5555
provider.EXPECT().Logger().Return(logger.CreateLogger()).AnyTimes()
5656
provider.EXPECT().DocumentSHA256Sums(gomock.Any()).Return([]byte(document), nil).AnyTimes()
57+
provider.EXPECT().RegistryHashes().Return(nil).AnyTimes()
5758

5859
return provider
5960
}
@@ -92,6 +93,7 @@ func mockProviderUpdateLock(t *testing.T, ctrl *gomock.Controller, address, vers
9293
provider.EXPECT().PackageDir().Return(packageDir).AnyTimes()
9394
provider.EXPECT().Logger().Return(logger.CreateLogger()).AnyTimes()
9495
provider.EXPECT().DocumentSHA256Sums(gomock.Any()).Return([]byte(document), nil).AnyTimes()
96+
provider.EXPECT().RegistryHashes().Return(nil).AnyTimes()
9597

9698
return provider
9799
}
@@ -268,6 +270,68 @@ provider "registry.terraform.io/hashicorp/template" {
268270
}
269271
}
270272

273+
// mockProviderWithRegistryHashes returns a mock Provider that exposes
274+
// per-platform hashes, matching the OpenTofu registry's `packages` response
275+
// field. In this path, lockfile generation skips the local PackageHashV1 /
276+
// shasums document fallback and uses the registry-supplied hashes.
277+
func mockProviderWithRegistryHashes(t *testing.T, ctrl *gomock.Controller, address, ver string, hashesByPlatform map[string][]getproviders.Hash) getproviders.Provider {
278+
t.Helper()
279+
280+
provider := mocks.NewMockProvider(ctrl)
281+
provider.EXPECT().Address().Return(address).AnyTimes()
282+
provider.EXPECT().Version().Return(ver).AnyTimes()
283+
provider.EXPECT().Constraints().Return("").AnyTimes()
284+
provider.EXPECT().Logger().Return(logger.CreateLogger()).AnyTimes()
285+
provider.EXPECT().RegistryHashes().Return(hashesByPlatform).AnyTimes()
286+
287+
return provider
288+
}
289+
290+
func TestMockUpdateLockfileWithRegistryHashes(t *testing.T) {
291+
t.Parallel()
292+
293+
ctrl := gomock.NewController(t)
294+
defer ctrl.Finish()
295+
296+
provider := mockProviderWithRegistryHashes(t, ctrl,
297+
"registry.opentofu.org/hashicorp/aws", "5.37.0",
298+
map[string][]getproviders.Hash{
299+
"linux_amd64": {
300+
"zh:aaaa000000000000000000000000000000000000000000000000000000000001",
301+
"h1:LinuxAmd64HashOpentofuRegistryProvidedValueAAA=",
302+
},
303+
"darwin_arm64": {
304+
"zh:bbbb000000000000000000000000000000000000000000000000000000000002",
305+
"h1:DarwinArm64HashOpentofuRegistryProvidedValueBBB=",
306+
},
307+
},
308+
)
309+
310+
workingDir := helpers.TmpDirWOSymlinks(t)
311+
lockfilePath := filepath.Join(workingDir, ".terraform.lock.hcl")
312+
313+
err := getproviders.UpdateLockfile(t.Context(), workingDir, []getproviders.Provider{provider})
314+
require.NoError(t, err)
315+
316+
actual, err := os.ReadFile(lockfilePath)
317+
require.NoError(t, err)
318+
319+
expected := `
320+
provider "registry.opentofu.org/hashicorp/aws" {
321+
version = "5.37.0"
322+
constraints = "5.37.0"
323+
hashes = [
324+
"h1:DarwinArm64HashOpentofuRegistryProvidedValueBBB=",
325+
"h1:LinuxAmd64HashOpentofuRegistryProvidedValueAAA=",
326+
"zh:aaaa000000000000000000000000000000000000000000000000000000000001",
327+
"zh:bbbb000000000000000000000000000000000000000000000000000000000002",
328+
]
329+
}
330+
`
331+
332+
assert.Equal(t, expected, string(actual))
333+
}
334+
271335
// TestMockUpdateLockfilePreservesAggregatedConstraints verifies that when a lock file
272336
// already has valid aggregated constraints from the full dependency tree (e.g.
273337
// ">= 2.0.0, >= 3.0.0, >= 4.9.0, < 7.0.0"), and the provider's module-only

internal/tf/getproviders/mocks/mock_provider.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)