github.com/opentofu/opentofu@v1.7.1/internal/depsfile/locks_file.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package depsfile
     7  
     8  import (
     9  	"fmt"
    10  	"sort"
    11  
    12  	"github.com/hashicorp/hcl/v2"
    13  	"github.com/hashicorp/hcl/v2/gohcl"
    14  	"github.com/hashicorp/hcl/v2/hclparse"
    15  	"github.com/hashicorp/hcl/v2/hclsyntax"
    16  	"github.com/hashicorp/hcl/v2/hclwrite"
    17  	"github.com/zclconf/go-cty/cty"
    18  
    19  	"github.com/opentofu/opentofu/internal/addrs"
    20  	"github.com/opentofu/opentofu/internal/getproviders"
    21  	"github.com/opentofu/opentofu/internal/replacefile"
    22  	"github.com/opentofu/opentofu/internal/tfdiags"
    23  	"github.com/opentofu/opentofu/version"
    24  )
    25  
    26  // LoadLocksFromFile reads locks from the given file, expecting it to be a
    27  // valid dependency lock file, or returns error diagnostics explaining why
    28  // that was not possible.
    29  //
    30  // The returned locks are a snapshot of what was present on disk at the time
    31  // the method was called. It does not take into account any subsequent writes
    32  // to the file, whether through this package's functions or by external
    33  // writers.
    34  //
    35  // If the returned diagnostics contains errors then the returned Locks may
    36  // be incomplete or invalid.
    37  func LoadLocksFromFile(filename string) (*Locks, tfdiags.Diagnostics) {
    38  	return loadLocks(func(parser *hclparse.Parser) (*hcl.File, hcl.Diagnostics) {
    39  		return parser.ParseHCLFile(filename)
    40  	})
    41  }
    42  
    43  // LoadLocksFromBytes reads locks from the given byte array, pretending that
    44  // it was read from the given filename.
    45  //
    46  // The constraints and behaviors are otherwise the same as for
    47  // LoadLocksFromFile. LoadLocksFromBytes is primarily to allow more convenient
    48  // integration testing (avoiding creating temporary files on disk); if you
    49  // are writing non-test code, consider whether LoadLocksFromFile might be
    50  // more appropriate to call.
    51  //
    52  // It is valid to use this with dependency lock information recorded as part of
    53  // a plan file, in which case the given filename will typically be a
    54  // placeholder that will only be seen in the unusual case that the plan file
    55  // contains an invalid lock file, which should only be possible if the user
    56  // edited it directly (OpenTofu bugs notwithstanding).
    57  func LoadLocksFromBytes(src []byte, filename string) (*Locks, tfdiags.Diagnostics) {
    58  	return loadLocks(func(parser *hclparse.Parser) (*hcl.File, hcl.Diagnostics) {
    59  		return parser.ParseHCL(src, filename)
    60  	})
    61  }
    62  
    63  func loadLocks(loadParse func(*hclparse.Parser) (*hcl.File, hcl.Diagnostics)) (*Locks, tfdiags.Diagnostics) {
    64  	ret := NewLocks()
    65  
    66  	var diags tfdiags.Diagnostics
    67  
    68  	parser := hclparse.NewParser()
    69  	f, hclDiags := loadParse(parser)
    70  	ret.sources = parser.Sources()
    71  	diags = diags.Append(hclDiags)
    72  	if f == nil {
    73  		// If we encountered an error loading the file then those errors
    74  		// should already be in diags from the above, but the file might
    75  		// also be nil itself and so we can't decode from it.
    76  		return ret, diags
    77  	}
    78  
    79  	moreDiags := decodeLocksFromHCL(ret, f.Body)
    80  	diags = diags.Append(moreDiags)
    81  	return ret, diags
    82  }
    83  
    84  // SaveLocksToFile writes the given locks object to the given file,
    85  // entirely replacing any content already in that file, or returns error
    86  // diagnostics explaining why that was not possible.
    87  //
    88  // SaveLocksToFile attempts an atomic replacement of the file, as an aid
    89  // to external tools such as text editor integrations that might be monitoring
    90  // the file as a signal to invalidate cached metadata. Consequently, other
    91  // temporary files may be temporarily created in the same directory as the
    92  // given filename during the operation.
    93  func SaveLocksToFile(locks *Locks, filename string) tfdiags.Diagnostics {
    94  	src, diags := SaveLocksToBytes(locks)
    95  	if diags.HasErrors() {
    96  		return diags
    97  	}
    98  
    99  	err := replacefile.AtomicWriteFile(filename, src, 0644)
   100  	if err != nil {
   101  		diags = diags.Append(tfdiags.Sourceless(
   102  			tfdiags.Error,
   103  			"Failed to update dependency lock file",
   104  			fmt.Sprintf("Error while writing new dependency lock information to %s: %s.", filename, err),
   105  		))
   106  		return diags
   107  	}
   108  
   109  	return diags
   110  }
   111  
   112  // SaveLocksToBytes writes the given locks object into a byte array,
   113  // using the same syntax that LoadLocksFromBytes expects to parse.
   114  func SaveLocksToBytes(locks *Locks) ([]byte, tfdiags.Diagnostics) {
   115  	var diags tfdiags.Diagnostics
   116  
   117  	// In other uses of the "hclwrite" package we typically try to make
   118  	// surgical updates to the author's existing files, preserving their
   119  	// block ordering, comments, etc. We intentionally don't do that here
   120  	// to reinforce the fact that this file primarily belongs to OpenTofu,
   121  	// and to help ensure that VCS diffs of the file primarily reflect
   122  	// changes that actually affect functionality rather than just cosmetic
   123  	// changes, by maintaining it in a highly-normalized form.
   124  
   125  	f := hclwrite.NewEmptyFile()
   126  	rootBody := f.Body()
   127  
   128  	// End-users _may_ edit the lock file in exceptional situations, like
   129  	// working around potential dependency selection bugs, but we intend it
   130  	// to be primarily maintained automatically by the "tofu init"
   131  	// command.
   132  	rootBody.AppendUnstructuredTokens(hclwrite.Tokens{
   133  		{
   134  			Type:  hclsyntax.TokenComment,
   135  			Bytes: []byte("# This file is maintained automatically by \"tofu init\".\n"),
   136  		},
   137  		{
   138  			Type:  hclsyntax.TokenComment,
   139  			Bytes: []byte("# Manual edits may be lost in future updates.\n"),
   140  		},
   141  	})
   142  
   143  	providers := make([]addrs.Provider, 0, len(locks.providers))
   144  	for provider := range locks.providers {
   145  		providers = append(providers, provider)
   146  	}
   147  	sort.Slice(providers, func(i, j int) bool {
   148  		return providers[i].LessThan(providers[j])
   149  	})
   150  
   151  	for _, provider := range providers {
   152  		lock := locks.providers[provider]
   153  		rootBody.AppendNewline()
   154  		block := rootBody.AppendNewBlock("provider", []string{lock.addr.String()})
   155  		body := block.Body()
   156  		body.SetAttributeValue("version", cty.StringVal(lock.version.String()))
   157  		if constraintsStr := getproviders.VersionConstraintsString(lock.versionConstraints); constraintsStr != "" {
   158  			body.SetAttributeValue("constraints", cty.StringVal(constraintsStr))
   159  		}
   160  		if len(lock.hashes) != 0 {
   161  			hashToks := encodeHashSetTokens(lock.hashes)
   162  			body.SetAttributeRaw("hashes", hashToks)
   163  		}
   164  	}
   165  
   166  	return f.Bytes(), diags
   167  }
   168  
   169  func decodeLocksFromHCL(locks *Locks, body hcl.Body) tfdiags.Diagnostics {
   170  	var diags tfdiags.Diagnostics
   171  
   172  	content, hclDiags := body.Content(&hcl.BodySchema{
   173  		Blocks: []hcl.BlockHeaderSchema{
   174  			{
   175  				Type:       "provider",
   176  				LabelNames: []string{"source_addr"},
   177  			},
   178  
   179  			// "module" is just a placeholder for future enhancement, so we
   180  			// can mostly-ignore the this block type we intend to add in
   181  			// future, but warn in case someone tries to use one e.g. if they
   182  			// downgraded to an earlier version of OpenTofu.
   183  			{
   184  				Type:       "module",
   185  				LabelNames: []string{"path"},
   186  			},
   187  		},
   188  	})
   189  	diags = diags.Append(hclDiags)
   190  
   191  	seenProviders := make(map[addrs.Provider]hcl.Range)
   192  	seenModule := false
   193  	for _, block := range content.Blocks {
   194  
   195  		switch block.Type {
   196  		case "provider":
   197  			lock, moreDiags := decodeProviderLockFromHCL(block)
   198  			diags = diags.Append(moreDiags)
   199  			if lock == nil {
   200  				continue
   201  			}
   202  			if previousRng, exists := seenProviders[lock.addr]; exists {
   203  				diags = diags.Append(&hcl.Diagnostic{
   204  					Severity: hcl.DiagError,
   205  					Summary:  "Duplicate provider lock",
   206  					Detail:   fmt.Sprintf("This lockfile already declared a lock for provider %s at %s.", lock.addr.String(), previousRng.String()),
   207  					Subject:  block.TypeRange.Ptr(),
   208  				})
   209  				continue
   210  			}
   211  			locks.providers[lock.addr] = lock
   212  			seenProviders[lock.addr] = block.DefRange
   213  
   214  		case "module":
   215  			// We'll just take the first module block to use for a single warning,
   216  			// because that's sufficient to get the point across without swamping
   217  			// the output with warning noise.
   218  			if !seenModule {
   219  				currentVersion := version.SemVer.String()
   220  				diags = diags.Append(&hcl.Diagnostic{
   221  					Severity: hcl.DiagWarning,
   222  					Summary:  "Dependency locks for modules are not yet supported",
   223  					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),
   224  					Subject:  block.TypeRange.Ptr(),
   225  				})
   226  				seenModule = true
   227  			}
   228  
   229  		default:
   230  			// Shouldn't get here because this should be exhaustive for
   231  			// all of the block types in the schema above.
   232  		}
   233  
   234  	}
   235  
   236  	return diags
   237  }
   238  
   239  func decodeProviderLockFromHCL(block *hcl.Block) (*ProviderLock, tfdiags.Diagnostics) {
   240  	ret := &ProviderLock{}
   241  	var diags tfdiags.Diagnostics
   242  
   243  	rawAddr := block.Labels[0]
   244  	addr, moreDiags := addrs.ParseProviderSourceString(rawAddr)
   245  	if moreDiags.HasErrors() {
   246  		// The diagnostics from ParseProviderSourceString are, as the name
   247  		// suggests, written with an intended audience of someone who is
   248  		// writing a "source" attribute in a provider requirement, not
   249  		// our lock file. Therefore we're using a less helpful, fixed error
   250  		// here, which is non-ideal but hopefully okay for now because we
   251  		// don't intend end-users to typically be hand-editing these anyway.
   252  		diags = diags.Append(&hcl.Diagnostic{
   253  			Severity: hcl.DiagError,
   254  			Summary:  "Invalid provider source address",
   255  			Detail:   "The provider source address for a provider lock must be a valid, fully-qualified address of the form \"hostname/namespace/type\".",
   256  			Subject:  block.LabelRanges[0].Ptr(),
   257  		})
   258  		return nil, diags
   259  	}
   260  	if !ProviderIsLockable(addr) {
   261  		if addr.IsBuiltIn() {
   262  			// A specialized error for built-in providers, because we have an
   263  			// explicit explanation for why those are not allowed.
   264  			diags = diags.Append(&hcl.Diagnostic{
   265  				Severity: hcl.DiagError,
   266  				Summary:  "Invalid provider source address",
   267  				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),
   268  				Subject:  block.LabelRanges[0].Ptr(),
   269  			})
   270  			return nil, diags
   271  		}
   272  		// Otherwise, we'll use a generic error message.
   273  		diags = diags.Append(&hcl.Diagnostic{
   274  			Severity: hcl.DiagError,
   275  			Summary:  "Invalid provider source address",
   276  			Detail:   fmt.Sprintf("Provider source address %s is a special provider that is not eligible for dependency locking.", addr),
   277  			Subject:  block.LabelRanges[0].Ptr(),
   278  		})
   279  		return nil, diags
   280  	}
   281  	if canonAddr := addr.String(); canonAddr != rawAddr {
   282  		// We also require the provider addresses in the lock file to be
   283  		// written in fully-qualified canonical form, so that it's totally
   284  		// clear to a reader which provider each block relates to. Again,
   285  		// we expect hand-editing of these to be atypical so it's reasonable
   286  		// to be stricter in parsing these than we would be in the main
   287  		// configuration.
   288  		diags = diags.Append(&hcl.Diagnostic{
   289  			Severity: hcl.DiagError,
   290  			Summary:  "Non-normalized provider source address",
   291  			Detail:   fmt.Sprintf("The provider source address for this provider lock must be written as %q, the fully-qualified and normalized form.", canonAddr),
   292  			Subject:  block.LabelRanges[0].Ptr(),
   293  		})
   294  		return nil, diags
   295  	}
   296  
   297  	ret.addr = addr
   298  
   299  	content, hclDiags := block.Body.Content(&hcl.BodySchema{
   300  		Attributes: []hcl.AttributeSchema{
   301  			{Name: "version", Required: true},
   302  			{Name: "constraints"},
   303  			{Name: "hashes"},
   304  		},
   305  	})
   306  	diags = diags.Append(hclDiags)
   307  
   308  	version, moreDiags := decodeProviderVersionArgument(addr, content.Attributes["version"])
   309  	ret.version = version
   310  	diags = diags.Append(moreDiags)
   311  
   312  	constraints, moreDiags := decodeProviderVersionConstraintsArgument(addr, content.Attributes["constraints"])
   313  	ret.versionConstraints = constraints
   314  	diags = diags.Append(moreDiags)
   315  
   316  	hashes, moreDiags := decodeProviderHashesArgument(addr, content.Attributes["hashes"])
   317  	ret.hashes = hashes
   318  	diags = diags.Append(moreDiags)
   319  
   320  	return ret, diags
   321  }
   322  
   323  func decodeProviderVersionArgument(provider addrs.Provider, attr *hcl.Attribute) (getproviders.Version, tfdiags.Diagnostics) {
   324  	var diags tfdiags.Diagnostics
   325  	if attr == nil {
   326  		// It's not okay to omit this argument, but the caller should already
   327  		// have generated diagnostics about that.
   328  		return getproviders.UnspecifiedVersion, diags
   329  	}
   330  	expr := attr.Expr
   331  
   332  	var raw *string
   333  	hclDiags := gohcl.DecodeExpression(expr, nil, &raw)
   334  	diags = diags.Append(hclDiags)
   335  	if hclDiags.HasErrors() {
   336  		return getproviders.UnspecifiedVersion, diags
   337  	}
   338  	if raw == nil {
   339  		diags = diags.Append(&hcl.Diagnostic{
   340  			Severity: hcl.DiagError,
   341  			Summary:  "Missing required argument",
   342  			Detail:   "A provider lock block must contain a \"version\" argument.",
   343  			Subject:  expr.Range().Ptr(), // the range for a missing argument's expression is the body's missing item range
   344  		})
   345  		return getproviders.UnspecifiedVersion, diags
   346  	}
   347  	version, err := getproviders.ParseVersion(*raw)
   348  	if err != nil {
   349  		diags = diags.Append(&hcl.Diagnostic{
   350  			Severity: hcl.DiagError,
   351  			Summary:  "Invalid provider version number",
   352  			Detail:   fmt.Sprintf("The selected version number for provider %s is invalid: %s.", provider, err),
   353  			Subject:  expr.Range().Ptr(),
   354  		})
   355  	}
   356  	if canon := version.String(); canon != *raw {
   357  		// Canonical forms are required in the lock file, to reduce the risk
   358  		// that a file diff will show changes that are entirely cosmetic.
   359  		diags = diags.Append(&hcl.Diagnostic{
   360  			Severity: hcl.DiagError,
   361  			Summary:  "Invalid provider version number",
   362  			Detail:   fmt.Sprintf("The selected version number for provider %s must be written in normalized form: %q.", provider, canon),
   363  			Subject:  expr.Range().Ptr(),
   364  		})
   365  	}
   366  	return version, diags
   367  }
   368  
   369  func decodeProviderVersionConstraintsArgument(provider addrs.Provider, attr *hcl.Attribute) (getproviders.VersionConstraints, tfdiags.Diagnostics) {
   370  	var diags tfdiags.Diagnostics
   371  	if attr == nil {
   372  		// It's okay to omit this argument.
   373  		return nil, diags
   374  	}
   375  	expr := attr.Expr
   376  
   377  	var raw string
   378  	hclDiags := gohcl.DecodeExpression(expr, nil, &raw)
   379  	diags = diags.Append(hclDiags)
   380  	if hclDiags.HasErrors() {
   381  		return nil, diags
   382  	}
   383  	constraints, err := getproviders.ParseVersionConstraints(raw)
   384  	if err != nil {
   385  		diags = diags.Append(&hcl.Diagnostic{
   386  			Severity: hcl.DiagError,
   387  			Summary:  "Invalid provider version constraints",
   388  			Detail:   fmt.Sprintf("The recorded version constraints for provider %s are invalid: %s.", provider, err),
   389  			Subject:  expr.Range().Ptr(),
   390  		})
   391  	}
   392  	if canon := getproviders.VersionConstraintsString(constraints); canon != raw {
   393  		// Canonical forms are required in the lock file, to reduce the risk
   394  		// that a file diff will show changes that are entirely cosmetic.
   395  		diags = diags.Append(&hcl.Diagnostic{
   396  			Severity: hcl.DiagError,
   397  			Summary:  "Invalid provider version constraints",
   398  			Detail:   fmt.Sprintf("The recorded version constraints for provider %s must be written in normalized form: %q.", provider, canon),
   399  			Subject:  expr.Range().Ptr(),
   400  		})
   401  	}
   402  
   403  	return constraints, diags
   404  }
   405  
   406  func decodeProviderHashesArgument(provider addrs.Provider, attr *hcl.Attribute) ([]getproviders.Hash, tfdiags.Diagnostics) {
   407  	var diags tfdiags.Diagnostics
   408  	if attr == nil {
   409  		// It's okay to omit this argument.
   410  		return nil, diags
   411  	}
   412  	expr := attr.Expr
   413  
   414  	// We'll decode this argument using the HCL static analysis mode, because
   415  	// there's no reason for the hashes list to be dynamic and this way we can
   416  	// give more precise feedback on individual elements that are invalid,
   417  	// with direct source locations.
   418  	hashExprs, hclDiags := hcl.ExprList(expr)
   419  	diags = diags.Append(hclDiags)
   420  	if hclDiags.HasErrors() {
   421  		return nil, diags
   422  	}
   423  	if len(hashExprs) == 0 {
   424  		diags = diags.Append(&hcl.Diagnostic{
   425  			Severity: hcl.DiagError,
   426  			Summary:  "Invalid provider hash set",
   427  			Detail:   "The \"hashes\" argument must either be omitted or contain at least one hash value.",
   428  			Subject:  expr.Range().Ptr(),
   429  		})
   430  		return nil, diags
   431  	}
   432  
   433  	ret := make([]getproviders.Hash, 0, len(hashExprs))
   434  	for _, hashExpr := range hashExprs {
   435  		var raw string
   436  		hclDiags := gohcl.DecodeExpression(hashExpr, nil, &raw)
   437  		diags = diags.Append(hclDiags)
   438  		if hclDiags.HasErrors() {
   439  			continue
   440  		}
   441  
   442  		hash, err := getproviders.ParseHash(raw)
   443  		if err != nil {
   444  			diags = diags.Append(&hcl.Diagnostic{
   445  				Severity: hcl.DiagError,
   446  				Summary:  "Invalid provider hash string",
   447  				Detail:   fmt.Sprintf("Cannot interpret %q as a provider hash: %s.", raw, err),
   448  				Subject:  expr.Range().Ptr(),
   449  			})
   450  			continue
   451  		}
   452  
   453  		ret = append(ret, hash)
   454  	}
   455  
   456  	return ret, diags
   457  }
   458  
   459  func encodeHashSetTokens(hashes []getproviders.Hash) hclwrite.Tokens {
   460  	// We'll generate the source code in a low-level way here (direct
   461  	// token manipulation) because it's desirable to maintain exactly
   462  	// the layout implemented here so that diffs against the locks
   463  	// file are easy to read; we don't want potential future changes to
   464  	// hclwrite to inadvertently introduce whitespace changes here.
   465  	ret := hclwrite.Tokens{
   466  		{
   467  			Type:  hclsyntax.TokenOBrack,
   468  			Bytes: []byte{'['},
   469  		},
   470  		{
   471  			Type:  hclsyntax.TokenNewline,
   472  			Bytes: []byte{'\n'},
   473  		},
   474  	}
   475  
   476  	// Although lock.hashes is a slice, we de-dupe and sort it on
   477  	// initialization so it's normalized for interpretation as a logical
   478  	// set, and so we can just trust it's already in a good order here.
   479  	for _, hash := range hashes {
   480  		hashVal := cty.StringVal(hash.String())
   481  		ret = append(ret, hclwrite.TokensForValue(hashVal)...)
   482  		ret = append(ret, hclwrite.Tokens{
   483  			{
   484  				Type:  hclsyntax.TokenComma,
   485  				Bytes: []byte{','},
   486  			},
   487  			{
   488  				Type:  hclsyntax.TokenNewline,
   489  				Bytes: []byte{'\n'},
   490  			},
   491  		}...)
   492  	}
   493  	ret = append(ret, &hclwrite.Token{
   494  		Type:  hclsyntax.TokenCBrack,
   495  		Bytes: []byte{']'},
   496  	})
   497  
   498  	return ret
   499  }