terraform/internal/depsfile/locks_file.go

438 lines
15 KiB
Go

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"
"github.com/hashicorp/terraform/tfdiags"
"github.com/hashicorp/terraform/version"
)
// LoadLocksFromFile reads locks from the given file, expecting it to be a
// valid dependency lock file, or returns error diagnostics explaining why
// that was not possible.
//
// The returned locks are a snapshot of what was present on disk at the time
// the method was called. It does not take into account any subsequent writes
// to the file, whether through this package's functions or by external
// writers.
//
// If the returned diagnostics contains errors then the returned Locks may
// be incomplete or invalid.
func LoadLocksFromFile(filename string) (*Locks, tfdiags.Diagnostics) {
ret := NewLocks()
var diags tfdiags.Diagnostics
parser := hclparse.NewParser()
f, hclDiags := parser.ParseHCLFile(filename)
ret.sources = parser.Sources()
diags = diags.Append(hclDiags)
moreDiags := decodeLocksFromHCL(ret, f.Body)
diags = diags.Append(moreDiags)
return ret, diags
}
// SaveLocksToFile writes the given locks object to the given file,
// entirely replacing any content already in that file, or returns error
// diagnostics explaining why that was not possible.
//
// SaveLocksToFile attempts an atomic replacement of the file, as an aid
// to external tools such as text editor integrations that might be monitoring
// the file as a signal to invalidate cached metadata. Consequently, other
// 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 {
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 {
var diags tfdiags.Diagnostics
content, hclDiags := body.Content(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "provider",
LabelNames: []string{"source_addr"},
},
// "module" is just a placeholder for future enhancement, so we
// can mostly-ignore the this block type we intend to add in
// future, but warn in case someone tries to use one e.g. if they
// downgraded to an earlier version of Terraform.
{
Type: "module",
LabelNames: []string{"path"},
},
},
})
diags = diags.Append(hclDiags)
seenProviders := make(map[addrs.Provider]hcl.Range)
seenModule := false
for _, block := range content.Blocks {
switch block.Type {
case "provider":
lock, moreDiags := decodeProviderLockFromHCL(block)
diags = diags.Append(moreDiags)
if lock == nil {
continue
}
if previousRng, exists := seenProviders[lock.addr]; exists {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate provider lock",
Detail: fmt.Sprintf("This lockfile already declared a lock for provider %s at %s.", lock.addr.String(), previousRng.String()),
Subject: block.TypeRange.Ptr(),
})
continue
}
locks.providers[lock.addr] = lock
seenProviders[lock.addr] = block.DefRange
case "module":
// We'll just take the first module block to use for a single warning,
// because that's sufficient to get the point across without swamping
// the output with warning noise.
if !seenModule {
currentVersion := version.SemVer.String()
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Dependency locks for modules are not yet supported",
Detail: fmt.Sprintf("Terraform v%s only supports dependency locks for providers, not for modules. This configuration may be intended for a later version of Terraform that also supports dependency locks for modules.", currentVersion),
Subject: block.TypeRange.Ptr(),
})
seenModule = true
}
default:
// Shouldn't get here because this should be exhaustive for
// all of the block types in the schema above.
}
}
return diags
}
func decodeProviderLockFromHCL(block *hcl.Block) (*ProviderLock, tfdiags.Diagnostics) {
ret := &ProviderLock{}
var diags tfdiags.Diagnostics
rawAddr := block.Labels[0]
addr, moreDiags := addrs.ParseProviderSourceString(rawAddr)
if moreDiags.HasErrors() {
// The diagnostics from ParseProviderSourceString are, as the name
// suggests, written with an intended audience of someone who is
// writing a "source" attribute in a provider requirement, not
// our lock file. Therefore we're using a less helpful, fixed error
// here, which is non-ideal but hopefully okay for now because we
// don't intend end-users to typically be hand-editing these anyway.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provider source address",
Detail: "The provider source address for a provider lock must be a valid, fully-qualified address of the form \"hostname/namespace/type\".",
Subject: block.LabelRanges[0].Ptr(),
})
return nil, diags
}
if !ProviderIsLockable(addr) {
if addr.IsBuiltIn() {
// A specialized error for built-in providers, because we have an
// explicit explanation for why those are not allowed.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provider source address",
Detail: fmt.Sprintf("Cannot lock a version for built-in provider %s. Built-in providers are bundled inside Terraform itself, so you can't select a version for them independently of the Terraform release you are currently running.", addr),
Subject: block.LabelRanges[0].Ptr(),
})
return nil, diags
}
// Otherwise, we'll use a generic error message.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provider source address",
Detail: fmt.Sprintf("Provider source address %s is a special provider that is not eligible for dependency locking.", addr),
Subject: block.LabelRanges[0].Ptr(),
})
return nil, diags
}
if canonAddr := addr.String(); canonAddr != rawAddr {
// We also require the provider addresses in the lock file to be
// written in fully-qualified canonical form, so that it's totally
// clear to a reader which provider each block relates to. Again,
// we expect hand-editing of these to be atypical so it's reasonable
// to be stricter in parsing these than we would be in the main
// configuration.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Non-normalized provider source address",
Detail: fmt.Sprintf("The provider source address for this provider lock must be written as %q, the fully-qualified and normalized form.", canonAddr),
Subject: block.LabelRanges[0].Ptr(),
})
return nil, diags
}
ret.addr = addr
// We'll decode the block body using gohcl, because we don't have any
// special structural validation to do other than what gohcl will naturally
// do for us here.
type RawHashes struct {
// We'll consume all of the attributes and process them dynamically.
Hashes hcl.Attributes `hcl:",remain"`
}
type Provider struct {
Version hcl.Expression `hcl:"version,attr"`
VersionConstraints hcl.Expression `hcl:"constraints,attr"`
HashesBlock *RawHashes `hcl:"hashes,block"`
}
var raw Provider
hclDiags := gohcl.DecodeBody(block.Body, nil, &raw)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
return ret, diags
}
version, moreDiags := decodeProviderVersionArgument(addr, raw.Version)
ret.version = version
diags = diags.Append(moreDiags)
constraints, moreDiags := decodeProviderVersionConstraintsArgument(addr, raw.VersionConstraints)
ret.versionConstraints = constraints
diags = diags.Append(moreDiags)
if raw.HashesBlock != nil {
hashes, moreDiags := decodeProviderHashesArgument(addr, raw.HashesBlock.Hashes)
ret.hashes = hashes
diags = diags.Append(moreDiags)
}
return ret, diags
}
func decodeProviderVersionArgument(provider addrs.Provider, expr hcl.Expression) (getproviders.Version, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var raw *string
hclDiags := gohcl.DecodeExpression(expr, nil, &raw)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
return getproviders.UnspecifiedVersion, diags
}
if raw == nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required argument",
Detail: "A provider lock block must contain a \"version\" argument.",
Subject: expr.Range().Ptr(), // the range for a missing argument's expression is the body's missing item range
})
return getproviders.UnspecifiedVersion, diags
}
version, err := getproviders.ParseVersion(*raw)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid version number",
Detail: fmt.Sprintf("The selected version number for provider %s is invalid: %s.", provider, err),
Subject: expr.Range().Ptr(),
})
}
if canon := version.String(); canon != *raw {
// Canonical forms are required in the lock file, to reduce the risk
// that a file diff will show changes that are entirely cosmetic.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid version number",
Detail: fmt.Sprintf("The selected version number for provider %s must be written in normalized form: %q.", provider, canon),
Subject: expr.Range().Ptr(),
})
}
return version, diags
}
func decodeProviderVersionConstraintsArgument(provider addrs.Provider, expr hcl.Expression) (getproviders.VersionConstraints, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var raw *string
hclDiags := gohcl.DecodeExpression(expr, nil, &raw)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
return nil, diags
}
if raw == nil {
// It's okay to omit this argument.
return nil, diags
}
constraints, err := getproviders.ParseVersionConstraints(*raw)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid version constraints",
Detail: fmt.Sprintf("The recorded version constraints for provider %s are invalid: %s.", provider, err),
Subject: expr.Range().Ptr(),
})
}
if canon := getproviders.VersionConstraintsString(constraints); canon != *raw {
// Canonical forms are required in the lock file, to reduce the risk
// that a file diff will show changes that are entirely cosmetic.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid version constraints",
Detail: fmt.Sprintf("The recorded version constraints for provider %s must be written in normalized form: %q.", provider, canon),
Subject: expr.Range().Ptr(),
})
}
return constraints, diags
}
func decodeProviderHashesArgument(provider addrs.Provider, attrs hcl.Attributes) (map[getproviders.Platform][]string, tfdiags.Diagnostics) {
if len(attrs) == 0 {
return nil, nil
}
ret := make(map[getproviders.Platform][]string, len(attrs))
var diags tfdiags.Diagnostics
for platformStr, attr := range attrs {
platform, err := getproviders.ParsePlatform(platformStr)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provider hash platform",
Detail: fmt.Sprintf("The string %q is not a valid platform specification: %s.", platformStr, err),
Subject: attr.NameRange.Ptr(),
})
continue
}
if canon := platform.String(); canon != platformStr {
// Canonical forms are required in the lock file, to reduce the risk
// that a file diff will show changes that are entirely cosmetic.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provider hash platform",
Detail: fmt.Sprintf("The platform specification %q must be written in the normalized form %q.", platformStr, canon),
Subject: attr.NameRange.Ptr(),
})
continue
}
var hashes []string
hclDiags := gohcl.DecodeExpression(attr.Expr, nil, &hashes)
diags = diags.Append(hclDiags)
if hclDiags.HasErrors() {
continue
}
// We don't validate the hashes, because we expect to support different
// hash formats over time and so we'll assume any that are in formats
// we don't understand are from later Terraform versions, or perhaps
// from an origin registry that is offering hashes aimed at a later
// Terraform version.
ret[platform] = hashes
}
return ret, diags
}