Skip to content

Commit bb940cb

Browse files
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>
1 parent dc4a0c0 commit bb940cb

File tree

3 files changed

+154
-31
lines changed

3 files changed

+154
-31
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'Fixes malformed Terraform version error when the remote backend reads a remote workspace that specifies a Terraform version constraint.'
3+
time: 2025-01-23T13:52:28.378207-08:00
4+
custom:
5+
Issue: "36356"

internal/backend/remote/backend.go

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -952,45 +952,55 @@ func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.D
952952
return nil
953953
}
954954

955-
remoteVersion, err := version.NewSemver(workspace.TerraformVersion)
955+
remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion)
956956
if err != nil {
957-
diags = diags.Append(tfdiags.Sourceless(
958-
tfdiags.Error,
959-
"Error looking up workspace",
960-
fmt.Sprintf("Invalid Terraform version: %s", err),
961-
))
957+
message := fmt.Sprintf(
958+
"The remote workspace specified an invalid Terraform version or constraint (%s), "+
959+
"and it isn't possible to determine whether the local Terraform version (%s) is compatible.",
960+
workspace.TerraformVersion,
961+
tfversion.String(),
962+
)
963+
diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
962964
return diags
963965
}
964966

965-
v014 := version.Must(version.NewSemver("0.14.0"))
966-
if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) {
967-
// Versions of Terraform prior to 0.14.0 will refuse to load state files
968-
// written by a newer version of Terraform, even if it is only a patch
969-
// level difference. As a result we require an exact match.
970-
if tfversion.SemVer.Equal(remoteVersion) {
971-
return diags
972-
}
973-
}
974-
if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) {
975-
// Versions of Terraform after 0.14.0 should be compatible with each
976-
// other. At the time this code was written, the only constraints we
977-
// are aware of are:
978-
//
979-
// - 0.14.0 is guaranteed to be compatible with versions up to but not
980-
// including 1.3.0
967+
remoteVersion, _ := version.NewSemver(workspace.TerraformVersion)
968+
969+
if remoteVersion != nil && remoteVersion.Prerelease() == "" {
970+
v014 := version.Must(version.NewSemver("0.14.0"))
981971
v130 := version.Must(version.NewSemver("1.3.0"))
982-
if tfversion.SemVer.LessThan(v130) && remoteVersion.LessThan(v130) {
983-
return diags
972+
973+
// Versions from 0.14 through the early 1.x series should be compatible
974+
// (though we don't know about 1.3 yet).
975+
if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v130) {
976+
early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v130.String()))
977+
if err != nil {
978+
panic(err)
979+
}
980+
remoteConstraint = early1xCompatible
984981
}
985-
// - Any new Terraform state version will require at least minor patch
986-
// increment, so x.y.* will always be compatible with each other
987-
tfvs := tfversion.SemVer.Segments64()
988-
rwvs := remoteVersion.Segments64()
989-
if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] {
990-
return diags
982+
983+
// Any future new state format will require at least a minor version
984+
// increment, so x.y.* will always be compatible with each other.
985+
if remoteVersion.GreaterThanOrEqual(v130) {
986+
rwvs := remoteVersion.Segments64()
987+
if len(rwvs) >= 3 {
988+
// ~> x.y.0
989+
minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1]))
990+
if err != nil {
991+
panic(err)
992+
}
993+
remoteConstraint = minorVersionCompatible
994+
}
991995
}
992996
}
993997

998+
fullTfversion := version.Must(version.NewSemver(tfversion.String()))
999+
1000+
if remoteConstraint.Check(fullTfversion) {
1001+
return diags
1002+
}
1003+
9941004
// Even if ignoring version conflicts, it may still be useful to call this
9951005
// method and warn the user about a mismatch between the local and remote
9961006
// Terraform versions.
@@ -1019,6 +1029,19 @@ func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.D
10191029
return diags
10201030
}
10211031

1032+
func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict bool) tfdiags.Diagnostic {
1033+
severity := tfdiags.Error
1034+
suggestion := ignoreRemoteVersionHelp
1035+
if ignoreVersionConflict {
1036+
severity = tfdiags.Warning
1037+
suggestion = ""
1038+
}
1039+
description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion))
1040+
return tfdiags.Sourceless(severity, "Incompatible Terraform version", description)
1041+
}
1042+
1043+
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."
1044+
10221045
func (b *Remote) IsLocalOperations() bool {
10231046
return b.forceLocal
10241047
}

internal/backend/remote/backend_test.go

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,11 +666,106 @@ func TestRemote_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) {
666666
if len(diags) != 1 {
667667
t.Fatal("expected diag, but none returned")
668668
}
669-
if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Invalid Terraform version") {
669+
if got := diags.Err().Error(); !strings.Contains(got, "The remote workspace specified an invalid Terraform version or constraint") {
670670
t.Fatalf("unexpected error: %s", got)
671671
}
672672
}
673673

674+
func TestRemote_VerifyWorkspaceTerraformVersion_versionConstraint(t *testing.T) {
675+
b, bCleanup := testBackendDefault(t)
676+
defer bCleanup()
677+
678+
// Define our test case struct
679+
type testCase struct {
680+
terraformVersion string
681+
versionConstraint string
682+
shouldSatisfy bool
683+
prerelease string
684+
}
685+
686+
// Create a slice of test cases
687+
testCases := []testCase{
688+
{
689+
terraformVersion: "1.8.0",
690+
versionConstraint: "> 1.9.0",
691+
shouldSatisfy: false,
692+
prerelease: "",
693+
},
694+
{
695+
terraformVersion: "1.10.1",
696+
versionConstraint: "~> 1.10.0",
697+
shouldSatisfy: true,
698+
prerelease: "",
699+
},
700+
{
701+
terraformVersion: "1.10.0",
702+
versionConstraint: "> 1.9.0",
703+
shouldSatisfy: true,
704+
prerelease: "",
705+
},
706+
{
707+
terraformVersion: "1.8.0",
708+
versionConstraint: "~> 1.9.0",
709+
shouldSatisfy: false,
710+
prerelease: "",
711+
},
712+
{
713+
terraformVersion: "1.10.0",
714+
versionConstraint: "> v1.9.4",
715+
shouldSatisfy: false,
716+
prerelease: "dev",
717+
},
718+
{
719+
terraformVersion: "1.10.0",
720+
versionConstraint: "> 1.10.0",
721+
shouldSatisfy: false,
722+
prerelease: "dev",
723+
},
724+
}
725+
726+
// Save and restore the actual version.
727+
p := tfversion.Prerelease
728+
v := tfversion.Version
729+
defer func() {
730+
tfversion.Prerelease = p
731+
tfversion.Version = v
732+
}()
733+
734+
// Now we loop through each test case, utilizing the values of each case
735+
// to setup our test and assert accordingly.
736+
for _, tc := range testCases {
737+
738+
tfversion.Prerelease = tc.prerelease
739+
tfversion.Version = tc.terraformVersion
740+
741+
// Update the mock remote workspace Terraform version to be a version constraint string
742+
if _, err := b.client.Workspaces.Update(
743+
context.Background(),
744+
b.organization,
745+
b.workspace,
746+
tfe.WorkspaceUpdateOptions{
747+
TerraformVersion: tfe.String(tc.versionConstraint),
748+
},
749+
); err != nil {
750+
t.Fatalf("error: %v", err)
751+
}
752+
diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName)
753+
754+
if tc.shouldSatisfy {
755+
if len(diags) > 0 {
756+
t.Fatalf("expected no diagnostics, but got: %v", diags.Err().Error())
757+
}
758+
} else {
759+
if len(diags) == 0 {
760+
t.Fatal("expected diagnostic, but none returned")
761+
}
762+
if got := diags.Err().Error(); !strings.Contains(got, "Terraform version mismatch") {
763+
t.Fatalf("unexpected error: %s", got)
764+
}
765+
}
766+
}
767+
}
768+
674769
func TestRemote_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) {
675770
b, bCleanup := testBackendDefault(t)
676771
defer bCleanup()

0 commit comments

Comments
 (0)