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 }