diff --git a/internal/depsfile/locks.go b/internal/depsfile/locks.go index ae1695293..701492807 100644 --- a/internal/depsfile/locks.go +++ b/internal/depsfile/locks.go @@ -1,6 +1,8 @@ package depsfile import ( + "sort" + "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/internal/getproviders" ) @@ -51,6 +53,11 @@ func (l *Locks) Provider(addr addrs.Provider) *ProviderLock { // invalidates any ProviderLock object previously returned from Provider or // SetProvider for the given provider address. func (l *Locks) SetProvider(addr addrs.Provider, version getproviders.Version, constraints getproviders.VersionConstraints, hashes map[getproviders.Platform][]string) *ProviderLock { + // Normalize the hash lists into a consistent order. + for _, slice := range hashes { + sort.Strings(slice) + } + new := &ProviderLock{ addr: addr, version: version, diff --git a/internal/depsfile/locks_file.go b/internal/depsfile/locks_file.go index 8b748c116..09b7053cd 100644 --- a/internal/depsfile/locks_file.go +++ b/internal/depsfile/locks_file.go @@ -2,10 +2,16 @@ package depsfile import ( "fmt" + "io/ioutil" + "os" + "sort" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/internal/getproviders" @@ -49,7 +55,95 @@ func LoadLocksFromFile(filename string) (*Locks, tfdiags.Diagnostics) { // temporary files may be temporarily created in the same directory as the // given filename during the operation. func SaveLocksToFile(locks *Locks, filename string) tfdiags.Diagnostics { - panic("SaveLocksToFile is not implemented yet") + var diags tfdiags.Diagnostics + + // In other uses of the "hclwrite" package we typically try to make + // surgical updates to the author's existing files, preserving their + // block ordering, comments, etc. We intentionally don't do that here + // to reinforce the fact that this file primarily belongs to Terraform, + // and to help ensure that VCS diffs of the file primarily reflect + // changes that actually affect functionality rather than just cosmetic + // changes, by maintaining it in a highly-normalized form. + + f := hclwrite.NewEmptyFile() + rootBody := f.Body() + + // End-users _may_ edit the lock file in exceptional situations, like + // working around potential dependency selection bugs, but we intend it + // to be primarily maintained automatically by the "terraform init" + // command. + rootBody.AppendUnstructuredTokens(hclwrite.Tokens{ + { + Type: hclsyntax.TokenComment, + Bytes: []byte("# This file is maintained automatically by \"terraform init\".\n"), + }, + { + Type: hclsyntax.TokenComment, + Bytes: []byte("# Manual edits may be lost in future updates.\n"), + }, + }) + + providers := make([]addrs.Provider, 0, len(locks.providers)) + for provider := range locks.providers { + providers = append(providers, provider) + } + sort.Slice(providers, func(i, j int) bool { + return providers[i].LessThan(providers[j]) + }) + + for _, provider := range providers { + lock := locks.providers[provider] + rootBody.AppendNewline() + block := rootBody.AppendNewBlock("provider", []string{lock.addr.String()}) + body := block.Body() + body.SetAttributeValue("version", cty.StringVal(lock.version.String())) + if constraintsStr := getproviders.VersionConstraintsString(lock.versionConstraints); constraintsStr != "" { + body.SetAttributeValue("constraints", cty.StringVal(constraintsStr)) + } + if len(lock.hashes) != 0 { + platforms := make([]getproviders.Platform, 0, len(lock.hashes)) + for platform := range lock.hashes { + platforms = append(platforms, platform) + } + sort.Slice(platforms, func(i, j int) bool { + return platforms[i].LessThan(platforms[j]) + }) + body.AppendNewline() + hashesBlock := body.AppendNewBlock("hashes", nil) + hashesBody := hashesBlock.Body() + for platform, hashes := range lock.hashes { + vals := make([]cty.Value, len(hashes)) + for i := range hashes { + vals[i] = cty.StringVal(hashes[i]) + } + var hashList cty.Value + if len(vals) > 0 { + hashList = cty.ListVal(vals) + } else { + hashList = cty.ListValEmpty(cty.String) + } + hashesBody.SetAttributeValue(platform.String(), hashList) + } + } + } + + newContent := f.Bytes() + + // TODO: Create the content in a new file and atomically pivot it into + // the target, so that there isn't a brief period where an incomplete + // file can be seen at the given location. + // But for now, this gets us started. + err := ioutil.WriteFile(filename, newContent, os.ModePerm) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to update dependency lock file", + fmt.Sprintf("Error while writing new dependency lock information to %s: %s.", filename, err), + )) + return diags + } + + return diags } func decodeLocksFromHCL(locks *Locks, body hcl.Body) tfdiags.Diagnostics { diff --git a/internal/depsfile/locks_file_test.go b/internal/depsfile/locks_file_test.go index 4d86207e3..a9b8dabf1 100644 --- a/internal/depsfile/locks_file_test.go +++ b/internal/depsfile/locks_file_test.go @@ -162,3 +162,67 @@ func TestLoadLocksFromFile(t *testing.T) { }) } } + +func TestSaveLocksToFile(t *testing.T) { + locks := NewLocks() + + fooProvider := addrs.MustParseProviderSourceString("test/foo") + barProvider := addrs.MustParseProviderSourceString("test/bar") + bazProvider := addrs.MustParseProviderSourceString("test/baz") + oneDotOh := getproviders.MustParseVersion("1.0.0") + oneDotTwo := getproviders.MustParseVersion("1.2.0") + atLeastOneDotOh := getproviders.MustParseVersionConstraints(">= 1.0.0") + pessimisticOneDotOh := getproviders.MustParseVersionConstraints("~> 1") + hashes := map[getproviders.Platform][]string{ + {OS: "riscos", Arch: "arm"}: { + "cccccccccccccccccccccccccccccccccccccccccccccccc", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } + locks.SetProvider(fooProvider, oneDotOh, atLeastOneDotOh, hashes) + locks.SetProvider(barProvider, oneDotTwo, pessimisticOneDotOh, nil) + locks.SetProvider(bazProvider, oneDotTwo, nil, nil) + + dir, err := ioutil.TempDir("", "terraform-internal-depsfile-savelockstofile") + if err != nil { + t.Fatal(err.Error()) + } + defer os.RemoveAll(dir) + + filename := filepath.Join(dir, LockFilePath) + diags := SaveLocksToFile(locks, filename) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + gotContentBytes, err := ioutil.ReadFile(filename) + if err != nil { + t.Fatalf(err.Error()) + } + gotContent := string(gotContentBytes) + wantContent := `# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/test/bar" { + version = "1.2.0" + constraints = "~> 1.0" +} + +provider "registry.terraform.io/test/baz" { + version = "1.2.0" +} + +provider "registry.terraform.io/test/foo" { + version = "1.0.0" + constraints = ">= 1.0.0" + + hashes { + riscos_arm = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "cccccccccccccccccccccccccccccccccccccccccccccccc"] + } +} +` + if diff := cmp.Diff(wantContent, gotContent); diff != "" { + t.Errorf("wrong result\n%s", diff) + } +}