From b43daeaa8d72d66f5a990ed2ffbe5942e12bcde3 Mon Sep 17 00:00:00 2001 From: Nick Fagerlund Date: Fri, 24 Sep 2021 13:08:03 -0700 Subject: [PATCH] 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. --- internal/cloud/backend.go | 68 ++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 2bf5d3c4d..418658417 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -742,10 +742,12 @@ func (b *Cloud) IgnoreVersionConflict() { } // VerifyWorkspaceTerraformVersion compares the local Terraform version against -// the workspace's configured Terraform version. If they are equal, this means -// that there are no compatibility concerns, so it returns no diagnostics. +// the workspace's configured Terraform version. If they are compatible, this +// 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 { var diags tfdiags.Diagnostics @@ -779,13 +781,55 @@ func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Di 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) 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 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Error looking up workspace", + fmt.Sprintf("Invalid Terraform version or version constraint: %s", err), + )) + 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( - tfdiags.Error, - "Error looking up workspace", - fmt.Sprintf("Invalid Terraform version: %s", err), + 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 } @@ -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( severity, "Terraform version mismatch",