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/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/replacefile" "github.com/hashicorp/terraform/internal/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) { 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 (Terraform 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 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 { 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 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 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 }