github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/depsfile/locks_file.go (about)

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