mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-28 01:41:48 -06:00
498 lines
17 KiB
Go
498 lines
17 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package depsfile
|
|
|
|
import (
|
|
"fmt"
|
|
"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/opentofu/opentofu/internal/addrs"
|
|
"github.com/opentofu/opentofu/internal/getproviders"
|
|
"github.com/opentofu/opentofu/internal/replacefile"
|
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
|
"github.com/opentofu/opentofu/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) {
|
|
return loadLocks(func(parser *hclparse.Parser) (*hcl.File, hcl.Diagnostics) {
|
|
return parser.ParseHCLFile(filename)
|
|
})
|
|
}
|
|
|
|
// LoadLocksFromBytes reads locks from the given byte array, pretending that
|
|
// it was read from the given filename.
|
|
//
|
|
// The constraints and behaviors are otherwise the same as for
|
|
// LoadLocksFromFile. LoadLocksFromBytes is primarily to allow more convenient
|
|
// integration testing (avoiding creating temporary files on disk); if you
|
|
// are writing non-test code, consider whether LoadLocksFromFile might be
|
|
// more appropriate to call.
|
|
//
|
|
// It is valid to use this with dependency lock information recorded as part of
|
|
// a plan file, in which case the given filename will typically be a
|
|
// placeholder that will only be seen in the unusual case that the plan file
|
|
// contains an invalid lock file, which should only be possible if the user
|
|
// edited it directly (OpenTofu bugs notwithstanding).
|
|
func LoadLocksFromBytes(src []byte, filename string) (*Locks, tfdiags.Diagnostics) {
|
|
return loadLocks(func(parser *hclparse.Parser) (*hcl.File, hcl.Diagnostics) {
|
|
return parser.ParseHCL(src, filename)
|
|
})
|
|
}
|
|
|
|
func loadLocks(loadParse func(*hclparse.Parser) (*hcl.File, hcl.Diagnostics)) (*Locks, tfdiags.Diagnostics) {
|
|
ret := NewLocks()
|
|
|
|
var diags tfdiags.Diagnostics
|
|
|
|
parser := hclparse.NewParser()
|
|
f, hclDiags := loadParse(parser)
|
|
ret.sources = parser.Sources()
|
|
diags = diags.Append(hclDiags)
|
|
if f == nil {
|
|
// If we encountered an error loading the file then those errors
|
|
// should already be in diags from the above, but the file might
|
|
// also be nil itself and so we can't decode from it.
|
|
return ret, diags
|
|
}
|
|
|
|
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 {
|
|
src, diags := SaveLocksToBytes(locks)
|
|
if diags.HasErrors() {
|
|
return diags
|
|
}
|
|
|
|
err := replacefile.AtomicWriteFile(filename, src, 0644)
|
|
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
|
|
}
|
|
|
|
// SaveLocksToBytes writes the given locks object into a byte array,
|
|
// using the same syntax that LoadLocksFromBytes expects to parse.
|
|
func SaveLocksToBytes(locks *Locks) ([]byte, 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 OpenTofu,
|
|
// 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 "tofu init"
|
|
// command.
|
|
rootBody.AppendUnstructuredTokens(hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenComment,
|
|
Bytes: []byte("# This file is maintained automatically by \"tofu 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 {
|
|
hashToks := encodeHashSetTokens(lock.hashes)
|
|
body.SetAttributeRaw("hashes", hashToks)
|
|
}
|
|
}
|
|
|
|
return f.Bytes(), 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 OpenTofu.
|
|
{
|
|
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("OpenTofu v%s only supports dependency locks for providers, not for modules. This configuration may be intended for a later version of OpenTofu 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 OpenTofu itself, so you can't select a version for them independently of the OpenTofu 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
|
|
|
|
content, hclDiags := block.Body.Content(&hcl.BodySchema{
|
|
Attributes: []hcl.AttributeSchema{
|
|
{Name: "version", Required: true},
|
|
{Name: "constraints"},
|
|
{Name: "hashes"},
|
|
},
|
|
})
|
|
diags = diags.Append(hclDiags)
|
|
|
|
version, moreDiags := decodeProviderVersionArgument(addr, content.Attributes["version"])
|
|
ret.version = version
|
|
diags = diags.Append(moreDiags)
|
|
|
|
constraints, moreDiags := decodeProviderVersionConstraintsArgument(addr, content.Attributes["constraints"])
|
|
ret.versionConstraints = constraints
|
|
diags = diags.Append(moreDiags)
|
|
|
|
hashes, moreDiags := decodeProviderHashesArgument(addr, content.Attributes["hashes"])
|
|
ret.hashes = hashes
|
|
diags = diags.Append(moreDiags)
|
|
|
|
return ret, diags
|
|
}
|
|
|
|
func decodeProviderVersionArgument(provider addrs.Provider, attr *hcl.Attribute) (getproviders.Version, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
if attr == nil {
|
|
// It's not okay to omit this argument, but the caller should already
|
|
// have generated diagnostics about that.
|
|
return getproviders.UnspecifiedVersion, diags
|
|
}
|
|
expr := attr.Expr
|
|
|
|
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 provider 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 provider 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, attr *hcl.Attribute) (getproviders.VersionConstraints, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
if attr == nil {
|
|
// It's okay to omit this argument.
|
|
return nil, diags
|
|
}
|
|
expr := attr.Expr
|
|
|
|
var raw string
|
|
hclDiags := gohcl.DecodeExpression(expr, nil, &raw)
|
|
diags = diags.Append(hclDiags)
|
|
if hclDiags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
constraints, err := getproviders.ParseVersionConstraints(raw)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider 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 provider 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, attr *hcl.Attribute) ([]getproviders.Hash, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
if attr == nil {
|
|
// It's okay to omit this argument.
|
|
return nil, diags
|
|
}
|
|
expr := attr.Expr
|
|
|
|
// We'll decode this argument using the HCL static analysis mode, because
|
|
// there's no reason for the hashes list to be dynamic and this way we can
|
|
// give more precise feedback on individual elements that are invalid,
|
|
// with direct source locations.
|
|
hashExprs, hclDiags := hcl.ExprList(expr)
|
|
diags = diags.Append(hclDiags)
|
|
if hclDiags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
if len(hashExprs) == 0 {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider hash set",
|
|
Detail: "The \"hashes\" argument must either be omitted or contain at least one hash value.",
|
|
Subject: expr.Range().Ptr(),
|
|
})
|
|
return nil, diags
|
|
}
|
|
|
|
ret := make([]getproviders.Hash, 0, len(hashExprs))
|
|
for _, hashExpr := range hashExprs {
|
|
var raw string
|
|
hclDiags := gohcl.DecodeExpression(hashExpr, nil, &raw)
|
|
diags = diags.Append(hclDiags)
|
|
if hclDiags.HasErrors() {
|
|
continue
|
|
}
|
|
|
|
hash, err := getproviders.ParseHash(raw)
|
|
if err != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid provider hash string",
|
|
Detail: fmt.Sprintf("Cannot interpret %q as a provider hash: %s.", raw, err),
|
|
Subject: expr.Range().Ptr(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
ret = append(ret, hash)
|
|
}
|
|
|
|
return ret, diags
|
|
}
|
|
|
|
func encodeHashSetTokens(hashes []getproviders.Hash) hclwrite.Tokens {
|
|
// We'll generate the source code in a low-level way here (direct
|
|
// token manipulation) because it's desirable to maintain exactly
|
|
// the layout implemented here so that diffs against the locks
|
|
// file are easy to read; we don't want potential future changes to
|
|
// hclwrite to inadvertently introduce whitespace changes here.
|
|
ret := hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenOBrack,
|
|
Bytes: []byte{'['},
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenNewline,
|
|
Bytes: []byte{'\n'},
|
|
},
|
|
}
|
|
|
|
// Although lock.hashes is a slice, we de-dupe and sort it on
|
|
// initialization so it's normalized for interpretation as a logical
|
|
// set, and so we can just trust it's already in a good order here.
|
|
for _, hash := range hashes {
|
|
hashVal := cty.StringVal(hash.String())
|
|
ret = append(ret, hclwrite.TokensForValue(hashVal)...)
|
|
ret = append(ret, hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenComma,
|
|
Bytes: []byte{','},
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenNewline,
|
|
Bytes: []byte{'\n'},
|
|
},
|
|
}...)
|
|
}
|
|
ret = append(ret, &hclwrite.Token{
|
|
Type: hclsyntax.TokenCBrack,
|
|
Bytes: []byte{']'},
|
|
})
|
|
|
|
return ret
|
|
}
|