diff --git a/internal/depsfile/doc.go b/internal/depsfile/doc.go new file mode 100644 index 000000000..a0f25b910 --- /dev/null +++ b/internal/depsfile/doc.go @@ -0,0 +1,22 @@ +// Package depsfile contains the logic for reading and writing Terraform's +// dependency lock and development override configuration files. +// +// These files are separate from the main Terraform configuration files (.tf) +// for a number of reasons. The first is to help establish a distinction +// where .tf files configure a particular module while these configure +// a whole configuration tree. Another, more practical consideration is that +// we intend both of these files to be primarily maintained automatically by +// Terraform itself, rather than by human-originated edits, and so keeping +// them separate means that it's easier to distinguish the files that Terraform +// will change automatically during normal workflow from the files that +// Terraform only edits on direct request. +// +// Both files use HCL syntax, for consistency with other files in Terraform +// that we expect humans to (in this case, only occasionally) edit directly. +// A dependency lock file tracks the most recently selected upstream versions +// of each dependency, and is intended for checkin to version control. +// A development override file allows for temporarily overriding upstream +// dependencies with local files/directories on disk as an aid to testing +// a cross-codebase change during development, and should not be saved in +// version control. +package depsfile diff --git a/internal/depsfile/locks.go b/internal/depsfile/locks.go new file mode 100644 index 000000000..ae1695293 --- /dev/null +++ b/internal/depsfile/locks.go @@ -0,0 +1,160 @@ +package depsfile + +import ( + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/getproviders" +) + +// Locks is the top-level type representing the information retained in a +// dependency lock file. +// +// Locks and the other types used within it are mutable via various setter +// methods, but they are not safe for concurrent modifications, so it's the +// caller's responsibility to prevent concurrent writes and writes concurrent +// with reads. +type Locks struct { + providers map[addrs.Provider]*ProviderLock + + // TODO: In future we'll also have module locks, but the design of that + // still needs some more work and we're deferring that to get the + // provider locking capability out sooner, because it's more common to + // directly depend on providers maintained outside your organization than + // modules maintained outside your organization. + + // sources is a copy of the map of source buffers produced by the HCL + // parser during loading, which we retain only so that the caller can + // use it to produce source code snippets in error messages. + sources map[string][]byte +} + +// NewLocks constructs and returns a new Locks object that initially contains +// no locks at all. +func NewLocks() *Locks { + return &Locks{ + providers: make(map[addrs.Provider]*ProviderLock), + + // no "sources" here, because that's only for locks objects loaded + // from files. + } +} + +// Provider returns the stored lock for the given provider, or nil if that +// provider currently has no lock. +func (l *Locks) Provider(addr addrs.Provider) *ProviderLock { + return l.providers[addr] +} + +// SetProvider creates a new lock or replaces the existing lock for the given +// provider. +// +// SetProvider returns the newly-created provider lock object, which +// invalidates any ProviderLock object previously returned from Provider or +// SetProvider for the given provider address. +func (l *Locks) SetProvider(addr addrs.Provider, version getproviders.Version, constraints getproviders.VersionConstraints, hashes map[getproviders.Platform][]string) *ProviderLock { + new := &ProviderLock{ + addr: addr, + version: version, + versionConstraints: constraints, + hashes: hashes, + } + l.providers[addr] = new + return new +} + +// Sources returns the source code of the file the receiver was generated from, +// or an empty map if the receiver wasn't generated from a file. +// +// This return type matches the one expected by HCL diagnostics printers to +// produce source code snapshots, which is the only intended use for this +// method. +func (l *Locks) Sources() map[string][]byte { + return l.sources +} + +// ProviderLock represents lock information for a specific provider. +type ProviderLock struct { + // addr is the address of the provider this lock applies to. + addr addrs.Provider + + // version is the specific version that was previously selected, while + // versionConstraints is the constraint that was used to make that + // selection, which we can potentially use to hint to run + // e.g. terraform init -upgrade if a user has changed a version + // constraint but the previous selection still remains valid. + // "version" is therefore authoritative, while "versionConstraints" is + // just for a UI hint and not used to make any real decisions. + version getproviders.Version + versionConstraints getproviders.VersionConstraints + + // hashes contains one or more hashes of packages or package contents + // for the package associated with the selected version on each supported + // architecture. + // + // hashes can contain a mixture of hashes in different formats to support + // changes over time. The new-style hash format is to have a string + // starting with "h" followed by a version number and then a colon, like + // "h1:" for the first hash format version. Other hash versions following + // this scheme may come later. These versioned hash schemes are implemented + // in the getproviders package; for example, "h1:" is implemented in + // getproviders.HashV1 . + // + // There is also a legacy hash format which is just a lowercase-hex-encoded + // SHA256 hash of the official upstream .zip file for the selected version. + // We'll allow as that a stop-gap until we can upgrade Terraform Registry + // to support the new scheme, but is non-ideal because we can verify it only + // when we have the original .zip file exactly; we can't verify a local + // directory containing the unpacked contents of that .zip file. + // + // We ideally want to populate hashes for all available architectures at + // once, by referring to the signed checksums file in the upstream + // registry. In that ideal case it's possible to later work with the same + // configuration on a different platform while still verifying the hashes. + // However, installation from any method other than an origin registry + // means we can only populate the hash for the current platform, and so + // it won't be possible to verify a subsequent installation of the same + // provider on a different platform. + hashes map[getproviders.Platform][]string +} + +// Provider returns the address of the provider this lock applies to. +func (l *ProviderLock) Provider() addrs.Provider { + return l.addr +} + +// Version returns the currently-selected version for the corresponding provider. +func (l *ProviderLock) Version() getproviders.Version { + return l.version +} + +// VersionConstraints returns the version constraints that were recorded as +// being used to choose the version returned by Version. +// +// These version constraints are not authoritative for future selections and +// are included only so Terraform can detect if the constraints in +// configuration have changed since a selection was made, and thus hint to the +// user that they may need to run terraform init -upgrade to apply the new +// constraints. +func (l *ProviderLock) VersionConstraints() getproviders.VersionConstraints { + return l.versionConstraints +} + +// HashesForPlatform returns all of the package hashes that were recorded for +// the given platform when this lock was created. If no hashes were recorded +// for that platform, the result is a zero-length slice. +// +// If your intent is to verify a package against the recorded hashes, use +// PreferredHashForPlatform to get a single hash which the current version +// of Terraform considers the strongest of the available hashes, which is +// the one that must pass for verification to be considered successful. +// +// Do not modify the backing array of the returned slice. +func (l *ProviderLock) HashesForPlatform(platform getproviders.Platform) []string { + return l.hashes[platform] +} + +// PreferredHashForPlatform returns a single hash which must match for a package +// for the given platform to be considered valid, or an empty string if there +// are no acceptable hashes recorded for the given platform. +func (l *ProviderLock) PreferredHashForPlatform(platform getproviders.Platform) string { + return getproviders.PreferredHash(l.hashes[platform]) +} diff --git a/internal/depsfile/locks_file.go b/internal/depsfile/locks_file.go new file mode 100644 index 000000000..8b748c116 --- /dev/null +++ b/internal/depsfile/locks_file.go @@ -0,0 +1,322 @@ +package depsfile + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclparse" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/getproviders" + "github.com/hashicorp/terraform/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) { + ret := NewLocks() + + var diags tfdiags.Diagnostics + + parser := hclparse.NewParser() + f, hclDiags := parser.ParseHCLFile(filename) + ret.sources = parser.Sources() + diags = diags.Append(hclDiags) + + 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 { + panic("SaveLocksToFile is not implemented yet") +} + +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 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 + + // We'll decode the block body using gohcl, because we don't have any + // special structural validation to do other than what gohcl will naturally + // do for us here. + type RawHashes struct { + // We'll consume all of the attributes and process them dynamically. + Hashes hcl.Attributes `hcl:",remain"` + } + type Provider struct { + Version hcl.Expression `hcl:"version,attr"` + VersionConstraints hcl.Expression `hcl:"constraints,attr"` + HashesBlock *RawHashes `hcl:"hashes,block"` + } + var raw Provider + hclDiags := gohcl.DecodeBody(block.Body, nil, &raw) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return ret, diags + } + + version, moreDiags := decodeProviderVersionArgument(addr, raw.Version) + ret.version = version + diags = diags.Append(moreDiags) + + constraints, moreDiags := decodeProviderVersionConstraintsArgument(addr, raw.VersionConstraints) + ret.versionConstraints = constraints + diags = diags.Append(moreDiags) + + if raw.HashesBlock != nil { + hashes, moreDiags := decodeProviderHashesArgument(addr, raw.HashesBlock.Hashes) + ret.hashes = hashes + diags = diags.Append(moreDiags) + } + + return ret, diags +} + +func decodeProviderVersionArgument(provider addrs.Provider, expr hcl.Expression) (getproviders.Version, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + 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 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 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, expr hcl.Expression) (getproviders.VersionConstraints, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + var raw *string + hclDiags := gohcl.DecodeExpression(expr, nil, &raw) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return nil, diags + } + if raw == nil { + // It's okay to omit this argument. + return nil, diags + } + constraints, err := getproviders.ParseVersionConstraints(*raw) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid 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 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, attrs hcl.Attributes) (map[getproviders.Platform][]string, tfdiags.Diagnostics) { + if len(attrs) == 0 { + return nil, nil + } + ret := make(map[getproviders.Platform][]string, len(attrs)) + var diags tfdiags.Diagnostics + + for platformStr, attr := range attrs { + platform, err := getproviders.ParsePlatform(platformStr) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid provider hash platform", + Detail: fmt.Sprintf("The string %q is not a valid platform specification: %s.", platformStr, err), + Subject: attr.NameRange.Ptr(), + }) + continue + } + if canon := platform.String(); canon != platformStr { + // 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 hash platform", + Detail: fmt.Sprintf("The platform specification %q must be written in the normalized form %q.", platformStr, canon), + Subject: attr.NameRange.Ptr(), + }) + continue + } + + var hashes []string + hclDiags := gohcl.DecodeExpression(attr.Expr, nil, &hashes) + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + continue + } + + // We don't validate the hashes, because we expect to support different + // hash formats over time and so we'll assume any that are in formats + // we don't understand are from later Terraform versions, or perhaps + // from an origin registry that is offering hashes aimed at a later + // Terraform version. + ret[platform] = hashes + } + + return ret, diags +} diff --git a/internal/depsfile/locks_file_test.go b/internal/depsfile/locks_file_test.go new file mode 100644 index 000000000..4d86207e3 --- /dev/null +++ b/internal/depsfile/locks_file_test.go @@ -0,0 +1,164 @@ +package depsfile + +import ( + "bufio" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/getproviders" + "github.com/hashicorp/terraform/tfdiags" +) + +func TestLoadLocksFromFile(t *testing.T) { + // For ease of test maintenance we treat every file under + // test-data/locks-files as a test case which is subject + // at least to testing that it produces an expected set + // of diagnostics represented via specially-formatted comments + // in the fixture files (which might be the empty set, if + // there are no such comments). + // + // Some of the files also have additional assertions that + // are encoded in the test code below. These must pass + // in addition to the standard diagnostics tests, if present. + files, err := ioutil.ReadDir("testdata/locks-files") + if err != nil { + t.Fatal(err.Error()) + } + + for _, info := range files { + testName := filepath.Base(info.Name()) + filename := filepath.Join("testdata/locks-files", testName) + t.Run(testName, func(t *testing.T) { + f, err := os.Open(filename) + if err != nil { + t.Fatal(err.Error()) + } + defer f.Close() + const errorPrefix = "# ERROR: " + const warningPrefix = "# WARNING: " + wantErrors := map[int]string{} + wantWarnings := map[int]string{} + sc := bufio.NewScanner(f) + lineNum := 1 + for sc.Scan() { + l := sc.Text() + if pos := strings.Index(l, errorPrefix); pos != -1 { + wantSummary := l[pos+len(errorPrefix):] + wantErrors[lineNum] = wantSummary + } + if pos := strings.Index(l, warningPrefix); pos != -1 { + wantSummary := l[pos+len(warningPrefix):] + wantWarnings[lineNum] = wantSummary + } + lineNum++ + } + if err := sc.Err(); err != nil { + t.Fatal(err.Error()) + } + + locks, diags := LoadLocksFromFile(filename) + gotErrors := map[int]string{} + gotWarnings := map[int]string{} + for _, diag := range diags { + summary := diag.Description().Summary + if diag.Source().Subject == nil { + // We don't expect any sourceless diagnostics here. + t.Errorf("unexpected sourceless diagnostic: %s", summary) + continue + } + lineNum := diag.Source().Subject.Start.Line + switch sev := diag.Severity(); sev { + case tfdiags.Error: + gotErrors[lineNum] = summary + case tfdiags.Warning: + gotWarnings[lineNum] = summary + default: + t.Errorf("unexpected diagnostic severity %s", sev) + } + } + + if diff := cmp.Diff(wantErrors, gotErrors); diff != "" { + t.Errorf("wrong errors\n%s", diff) + } + if diff := cmp.Diff(wantWarnings, gotWarnings); diff != "" { + t.Errorf("wrong warnings\n%s", diff) + } + + switch testName { + // These are the file-specific test assertions. Not all files + // need custom test assertions in addition to the standard + // diagnostics assertions implemented above, so the cases here + // don't need to be exhaustive for all files. + // + // Please keep these in alphabetical order so the list is easy + // to scan! + + case "empty.hcl": + if got, want := len(locks.providers), 0; got != want { + t.Errorf("wrong number of providers %d; want %d", got, want) + } + + case "valid-provider-locks.hcl": + if got, want := len(locks.providers), 3; got != want { + t.Errorf("wrong number of providers %d; want %d", got, want) + } + + t.Run("version-only", func(t *testing.T) { + if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/version-only")); lock != nil { + if got, want := lock.Version().String(), "1.0.0"; got != want { + t.Errorf("wrong version\ngot: %s\nwant: %s", got, want) + } + if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), ""; got != want { + t.Errorf("wrong version constraints\ngot: %s\nwant: %s", got, want) + } + if got, want := len(lock.hashes), 0; got != want { + t.Errorf("wrong number of hashes %d; want %d", got, want) + } + } + }) + + t.Run("version-and-constraints", func(t *testing.T) { + if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/version-and-constraints")); lock != nil { + if got, want := lock.Version().String(), "1.2.0"; got != want { + t.Errorf("wrong version\ngot: %s\nwant: %s", got, want) + } + if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), "~> 1.2"; got != want { + t.Errorf("wrong version constraints\ngot: %s\nwant: %s", got, want) + } + if got, want := len(lock.hashes), 0; got != want { + t.Errorf("wrong number of hashes %d; want %d", got, want) + } + } + }) + + t.Run("all-the-things", func(t *testing.T) { + if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/all-the-things")); lock != nil { + if got, want := lock.Version().String(), "3.0.10"; got != want { + t.Errorf("wrong version\ngot: %s\nwant: %s", got, want) + } + if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), ">= 3.0.2"; got != want { + t.Errorf("wrong version constraints\ngot: %s\nwant: %s", got, want) + } + wantHashes := map[getproviders.Platform][]string{ + {OS: "amigaos", Arch: "m68k"}: { + "placeholder-hash-1", + }, + {OS: "tos", Arch: "m68k"}: { + "placeholder-hash-2", + "placeholder-hash-3", + }, + } + if diff := cmp.Diff(wantHashes, lock.hashes); diff != "" { + t.Errorf("wrong hashes\n%s", diff) + } + } + }) + } + }) + } +} diff --git a/internal/depsfile/paths.go b/internal/depsfile/paths.go new file mode 100644 index 000000000..252f67e20 --- /dev/null +++ b/internal/depsfile/paths.go @@ -0,0 +1,18 @@ +package depsfile + +// LockFilePath is the path, relative to a configuration's root module +// directory, where Terraform expects to find the dependency lock file for +// that configuration. +// +// This file is intended to be kept in version control, so it lives directly +// in the root module directory. The ".terraform" prefix is intended to +// suggest that it's metadata about several types of objects that ultimately +// end up in the .terraform directory after running "terraform init". +const LockFilePath = ".terraform.lock.hcl" + +// DevOverrideFilePath is the path, relative to a configuration's root module +// directory, where Terraform will look to find a possible override file that +// represents a request to temporarily (within a single working directory only) +// use specific local directories in place of packages that would normally +// need to be installed from a remote location. +const DevOverrideFilePath = ".terraform/dev-overrides.hcl" diff --git a/internal/depsfile/testdata/locks-files/empty.hcl b/internal/depsfile/testdata/locks-files/empty.hcl new file mode 100644 index 000000000..66169c2ef --- /dev/null +++ b/internal/depsfile/testdata/locks-files/empty.hcl @@ -0,0 +1 @@ +# An empty locks file is a bit of a degenerate case, but it is valid. diff --git a/internal/depsfile/testdata/locks-files/invalid-provider-addrs.hcl b/internal/depsfile/testdata/locks-files/invalid-provider-addrs.hcl new file mode 100644 index 000000000..a632de5d7 --- /dev/null +++ b/internal/depsfile/testdata/locks-files/invalid-provider-addrs.hcl @@ -0,0 +1,34 @@ +provider "" { # ERROR: Invalid provider source address + +} + +provider "hashicorp/aws" { # ERROR: Non-normalized provider source address + +} + +provider "aws" { # ERROR: Non-normalized provider source address + +} + +provider "too/many/parts/here" { # ERROR: Invalid provider source address + +} + +provider "Registry.terraform.io/example/example" { # ERROR: Non-normalized provider source address + +} + +provider "registry.terraform.io/eXample/example" { # ERROR: Non-normalized provider source address + +} + +provider "registry.terraform.io/example/Example" { # ERROR: Non-normalized provider source address + +} + +provider "this/one/okay" { + version = "1.0.0" +} + +provider "this/one/okay" { # ERROR: Duplicate provider lock +} diff --git a/internal/depsfile/testdata/locks-files/invalid-versions.hcl b/internal/depsfile/testdata/locks-files/invalid-versions.hcl new file mode 100644 index 000000000..c2f765fcd --- /dev/null +++ b/internal/depsfile/testdata/locks-files/invalid-versions.hcl @@ -0,0 +1,30 @@ +provider "terraform.io/test/foo" { + version = "" # ERROR: Invalid version number +} + +provider "terraform.io/test/bar" { + # The "v" prefix is not expected here + version = "v1.0.0" # ERROR: Invalid version number +} + +provider "terraform.io/test/baz" { + # Must be written in the canonical form, with three parts + version = "1.0" # ERROR: Invalid version number +} + +provider "terraform.io/test/boop" { + # Must be written in the canonical form, with three parts + version = "1" # ERROR: Invalid version number +} + +provider "terraform.io/test/blep" { + # Mustn't use redundant extra zero padding + version = "1.02" # ERROR: Invalid version number +} + +provider "terraform.io/test/huzzah" { # ERROR: Missing required argument +} + +provider "terraform.io/test/huzznot" { + version = null # ERROR: Missing required argument +} diff --git a/internal/depsfile/testdata/locks-files/unsupported-block.hcl b/internal/depsfile/testdata/locks-files/unsupported-block.hcl new file mode 100644 index 000000000..41321fca9 --- /dev/null +++ b/internal/depsfile/testdata/locks-files/unsupported-block.hcl @@ -0,0 +1,2 @@ +doodad "blah" { # ERROR: Unsupported block type +} diff --git a/internal/depsfile/testdata/locks-files/valid-provider-locks.hcl b/internal/depsfile/testdata/locks-files/valid-provider-locks.hcl new file mode 100644 index 000000000..878c8d0c2 --- /dev/null +++ b/internal/depsfile/testdata/locks-files/valid-provider-locks.hcl @@ -0,0 +1,24 @@ + +provider "terraform.io/test/version-only" { + version = "1.0.0" +} + +provider "terraform.io/test/version-and-constraints" { + version = "1.2.0" + constraints = "~> 1.2" +} + +provider "terraform.io/test/all-the-things" { + version = "3.0.10" + constraints = ">= 3.0.2" + + hashes { + amigaos_m68k = [ + "placeholder-hash-1", + ] + tos_m68k = [ + "placeholder-hash-2", + "placeholder-hash-3", + ] + } +} diff --git a/internal/getproviders/types.go b/internal/getproviders/types.go index 44eab6ccd..2fa0c81af 100644 --- a/internal/getproviders/types.go +++ b/internal/getproviders/types.go @@ -346,7 +346,7 @@ func (l PackageMetaList) FilterProviderPlatformExactVersion(provider addrs.Provi return ret } -// VersionConstraintsString returns a UI-oriented string representation of +// VersionConstraintsString returns a canonical string representation of // a VersionConstraints value. func VersionConstraintsString(spec VersionConstraints) string { // (we have our own function for this because the upstream versions @@ -355,6 +355,12 @@ func VersionConstraintsString(spec VersionConstraints) string { // function to do this later, but having this in here avoids blocking on // that and this is the sort of thing that is unlikely to need ongoing // maintenance because the version constraint syntax is unlikely to change.) + // + // ParseVersionConstraints allows some variations for convenience, but the + // return value from this function serves as the normalized form of a + // particular version constraint, which is the form we require in dependency + // lock files. Therefore the canonical forms produced here are a compatibility + // constraint for the dependency lock file parser. var b strings.Builder for i, sel := range spec {