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 }