diff --git a/.changes/unreleased/BUG FIXES-20250123-135228.yaml b/.changes/unreleased/BUG FIXES-20250123-135228.yaml new file mode 100644 index 000000000000..3e6f836a0f9e --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20250123-135228.yaml @@ -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" diff --git a/internal/backend/remote/backend.go b/internal/backend/remote/backend.go index e956255b0b61..426ccfb6645b 100644 --- a/internal/backend/remote/backend.go +++ b/internal/backend/remote/backend.go @@ -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 } diff --git a/internal/backend/remote/backend_test.go b/internal/backend/remote/backend_test.go index c9ef63517c5a..87c3fd40ad5b 100644 --- a/internal/backend/remote/backend_test.go +++ b/internal/backend/remote/backend_test.go @@ -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()