Fix error reading remote workspace with version constraint (#36356)

* Update backend.go

* remove comments

* Change error string

* Add to changelog

* add yaml to changelog

* Change yaml format

* generate changelog with changie instead of manually

* Add new test

* Change issue to PR number

* Update .changes/unreleased/BUG FIXES-20250123-135228.yaml

Co-authored-by: Sebastian Rivera <sebastian.rivera@hashicorp.com>

* Update backend_test.go

* Update backend_test.go

* Update backend_test.go

* Update backend_test.go

* Update internal/backend/remote/backend_test.go

Co-authored-by: Sebastian Rivera <sebastian.rivera@hashicorp.com>

---------

Co-authored-by: Sebastian Rivera <sebastian.rivera@hashicorp.com>
pull/36408/head^2
Shweta 1 year ago committed by GitHub
parent dc4a0c0dae
commit bb940cbfd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'Fixes malformed Terraform version error when the remote backend reads a remote workspace that specifies a Terraform version constraint.'
time: 2025-01-23T13:52:28.378207-08:00
custom:
Issue: "36356"

@ -952,45 +952,55 @@ func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.D
return nil
}
remoteVersion, err := version.NewSemver(workspace.TerraformVersion)
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: %s", err),
))
message := fmt.Sprintf(
"The remote workspace specified an invalid Terraform version or constraint (%s), "+
"and it isn't possible to determine whether the local Terraform version (%s) is compatible.",
workspace.TerraformVersion,
tfversion.String(),
)
diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
return diags
}
v014 := version.Must(version.NewSemver("0.14.0"))
if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) {
// Versions of Terraform prior to 0.14.0 will refuse to load state files
// written by a newer version of Terraform, even if it is only a patch
// level difference. As a result we require an exact match.
if tfversion.SemVer.Equal(remoteVersion) {
return diags
}
}
if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) {
// Versions of Terraform after 0.14.0 should be compatible with each
// other. At the time this code was written, the only constraints we
// are aware of are:
//
// - 0.14.0 is guaranteed to be compatible with versions up to but not
// including 1.3.0
remoteVersion, _ := version.NewSemver(workspace.TerraformVersion)
if remoteVersion != nil && remoteVersion.Prerelease() == "" {
v014 := version.Must(version.NewSemver("0.14.0"))
v130 := version.Must(version.NewSemver("1.3.0"))
if tfversion.SemVer.LessThan(v130) && remoteVersion.LessThan(v130) {
return diags
// Versions from 0.14 through the early 1.x series should be compatible
// (though we don't know about 1.3 yet).
if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v130) {
early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v130.String()))
if err != nil {
panic(err)
}
remoteConstraint = early1xCompatible
}
// - Any new Terraform state version will require at least minor patch
// increment, so x.y.* will always be compatible with each other
tfvs := tfversion.SemVer.Segments64()
rwvs := remoteVersion.Segments64()
if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] {
return diags
// Any future new state format will require at least a minor version
// increment, so x.y.* will always be compatible with each other.
if remoteVersion.GreaterThanOrEqual(v130) {
rwvs := remoteVersion.Segments64()
if len(rwvs) >= 3 {
// ~> x.y.0
minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1]))
if err != nil {
panic(err)
}
remoteConstraint = minorVersionCompatible
}
}
}
fullTfversion := version.Must(version.NewSemver(tfversion.String()))
if remoteConstraint.Check(fullTfversion) {
return diags
}
// 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.
@ -1019,6 +1029,19 @@ func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.D
return diags
}
func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict bool) tfdiags.Diagnostic {
severity := tfdiags.Error
suggestion := ignoreRemoteVersionHelp
if ignoreVersionConflict {
severity = tfdiags.Warning
suggestion = ""
}
description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion))
return tfdiags.Sourceless(severity, "Incompatible Terraform version", description)
}
const ignoreRemoteVersionHelp = "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."
func (b *Remote) IsLocalOperations() bool {
return b.forceLocal
}

@ -666,11 +666,106 @@ func TestRemote_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) {
if len(diags) != 1 {
t.Fatal("expected diag, but none returned")
}
if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Invalid Terraform version") {
if got := diags.Err().Error(); !strings.Contains(got, "The remote workspace specified an invalid Terraform version or constraint") {
t.Fatalf("unexpected error: %s", got)
}
}
func TestRemote_VerifyWorkspaceTerraformVersion_versionConstraint(t *testing.T) {
b, bCleanup := testBackendDefault(t)
defer bCleanup()
// Define our test case struct
type testCase struct {
terraformVersion string
versionConstraint string
shouldSatisfy bool
prerelease string
}
// Create a slice of test cases
testCases := []testCase{
{
terraformVersion: "1.8.0",
versionConstraint: "> 1.9.0",
shouldSatisfy: false,
prerelease: "",
},
{
terraformVersion: "1.10.1",
versionConstraint: "~> 1.10.0",
shouldSatisfy: true,
prerelease: "",
},
{
terraformVersion: "1.10.0",
versionConstraint: "> 1.9.0",
shouldSatisfy: true,
prerelease: "",
},
{
terraformVersion: "1.8.0",
versionConstraint: "~> 1.9.0",
shouldSatisfy: false,
prerelease: "",
},
{
terraformVersion: "1.10.0",
versionConstraint: "> v1.9.4",
shouldSatisfy: false,
prerelease: "dev",
},
{
terraformVersion: "1.10.0",
versionConstraint: "> 1.10.0",
shouldSatisfy: false,
prerelease: "dev",
},
}
// Save and restore the actual version.
p := tfversion.Prerelease
v := tfversion.Version
defer func() {
tfversion.Prerelease = p
tfversion.Version = v
}()
// Now we loop through each test case, utilizing the values of each case
// to setup our test and assert accordingly.
for _, tc := range testCases {
tfversion.Prerelease = tc.prerelease
tfversion.Version = tc.terraformVersion
// Update the mock remote workspace Terraform version to be a version constraint string
if _, err := b.client.Workspaces.Update(
context.Background(),
b.organization,
b.workspace,
tfe.WorkspaceUpdateOptions{
TerraformVersion: tfe.String(tc.versionConstraint),
},
); err != nil {
t.Fatalf("error: %v", err)
}
diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName)
if tc.shouldSatisfy {
if len(diags) > 0 {
t.Fatalf("expected no diagnostics, but got: %v", diags.Err().Error())
}
} else {
if len(diags) == 0 {
t.Fatal("expected diagnostic, but none returned")
}
if got := diags.Err().Error(); !strings.Contains(got, "Terraform version mismatch") {
t.Fatalf("unexpected error: %s", got)
}
}
}
}
func TestRemote_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) {
b, bCleanup := testBackendDefault(t)
defer bCleanup()

Loading…
Cancel
Save