Cloud backend: accept version constraints from workspaces

The cloud backend (and remote before it) previously expected a TFC workspace's
`terraform-version` attribute to be either the magic string `"latest"` or an
explicit semver value. But a workspace might have a version constraint instead
(like `~> 1.1.0`), in which case the version check would blow up.

This commit checks whether `terraform-version` is a valid version constraint
before erroring out, and if so, returns success if the local version meets the
constraint.

Because it's not practical to deeply introspect the slice of version space
defined by a constraint, this check is slightly less robust than the version
comparisons below it:

- It can give a false OK on open-ended constraints like `>= 1.1.0`. Say you're
  running 1.3.0, it changed the state format, and the TFE instance admin has
  not yet added any 1.3.x Terraform versions; your workspace will now break.

- It will give a false not-OK when using different minor versions within a range
  that we know to be compatible, e.g. remote constraint of `~> 0.15.0` and local
  version of 1.1.0.

- This would be totally useless with the pre-0.14 versions of Terraform, where
  patch releases could change state format... but we're not going back in time
  to add this feature to them anyway.

Still, in the most common likely case (`~> x.y.z`), it'll complain at you (with
an error you can choose to override) if you're not using the same minor version,
and that seems proportionate, useful, and expected.
This commit is contained in:
Nick Fagerlund 2021-09-24 13:08:03 -07:00 committed by Chris Arcand
parent 0bae48bc01
commit b43daeaa8d
1 changed files with 50 additions and 18 deletions

View File

@ -742,10 +742,12 @@ func (b *Cloud) IgnoreVersionConflict() {
} }
// VerifyWorkspaceTerraformVersion compares the local Terraform version against // VerifyWorkspaceTerraformVersion compares the local Terraform version against
// the workspace's configured Terraform version. If they are equal, this means // the workspace's configured Terraform version. If they are compatible, this
// that there are no compatibility concerns, so it returns no diagnostics. // means that there are no state compatibility concerns, so it returns no
// diagnostics.
// //
// If the versions differ, // If the versions aren't compatible, it returns an error (or, if
// b.ignoreVersionConflict is set, a warning).
func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
@ -779,16 +781,58 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di
return nil return nil
} }
// Even if ignoring version conflicts, it may still be useful to call this
// method and warn the user about a mismatch between the local and remote
// Terraform versions.
severity := tfdiags.Error
if b.ignoreVersionConflict {
severity = tfdiags.Warning
}
suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace."
if b.ignoreVersionConflict {
suggestion = ""
}
remoteVersion, err := version.NewSemver(workspace.TerraformVersion) remoteVersion, err := version.NewSemver(workspace.TerraformVersion)
if err != nil {
// If it's not a valid version, it might be a valid version constraint:
remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion)
if err != nil { if err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
"Error looking up workspace", "Error looking up workspace",
fmt.Sprintf("Invalid Terraform version: %s", err), fmt.Sprintf("Invalid Terraform version or version constraint: %s", err),
)) ))
return diags return diags
} }
// Avoiding tfversion.SemVer because it omits the prerelease prefix, and we
// want constraints like `~> 1.2.0-beta1` to be possible.
fullTfversion := version.Must(version.NewSemver(tfversion.String()))
// If it's a constraint, we only ensure that the local version meets it.
// This can result in both false positives and false negatives, but in the
// most common case (~> x.y.z) it's useful enough.
if remoteConstraint.Check(fullTfversion) {
return diags
}
diags = diags.Append(tfdiags.Sourceless(
severity,
"Terraform version mismatch",
fmt.Sprintf(
"The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).%s",
tfversion.String(),
b.organization,
workspace.Name,
workspace.TerraformVersion,
suggestion,
),
))
return diags
}
v014 := version.Must(version.NewSemver("0.14.0")) v014 := version.Must(version.NewSemver("0.14.0"))
if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) { if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) {
// Versions of Terraform prior to 0.14.0 will refuse to load state files // Versions of Terraform prior to 0.14.0 will refuse to load state files
@ -818,18 +862,6 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di
} }
} }
// Even if ignoring version conflicts, it may still be useful to call this
// method and warn the user about a mismatch between the local and remote
// Terraform versions.
severity := tfdiags.Error
if b.ignoreVersionConflict {
severity = tfdiags.Warning
}
suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace."
if b.ignoreVersionConflict {
suggestion = ""
}
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
severity, severity,
"Terraform version mismatch", "Terraform version mismatch",