cloud: Set minimum TFE version

These changes remove all of the preexisting version checking for
individual features, wiping the slate clean with an overall minimum
requirement of a future TFP-API-Version 2.5, which at the time of this
writing is expected to be TFE v202112-1.

It also actually provides that expected TFE version as an actionable
error message, rather than generically saying that it isn't supported or
using the somewhat opaque API version header.
pull/29826/head
Chris Arcand 5 years ago
parent 46e47ed379
commit 7cc53fe163

@ -265,10 +265,10 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics {
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to create the Terraform Enterprise client",
"Failed to create the Terraform Cloud/Enterprise client",
fmt.Sprintf(
`Encountered an unexpected error while creating the `+
`Terraform Enterprise client: %s.`, err,
`Terraform Cloud/Enterprise client: %s.`, err,
),
))
return diags
@ -293,6 +293,25 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics {
return diags
}
// Check for the minimum version of Terraform Enterprise required.
//
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
// so if there's an error when parsing the RemoteAPIVersion, it's handled as
// equivalent to an API version < 2.3.
currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
desiredAPIVersion, _ := version.NewVersion("2.5")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Unsupported Terraform Enterprise version",
fmt.Sprintf(
`The 'cloud' option requires Terraform Enterprise %s or later.`,
apiToMinimumTFEVersion["2.5"],
),
))
}
// Configure a local backend for when we need to run operations locally.
b.local = backendLocal.NewWithBackend(b)
b.forceLocal = b.forceLocal || !entitlements.Operations

@ -8,7 +8,6 @@ import (
"log"
tfe "github.com/hashicorp/go-tfe"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terraform"
@ -87,75 +86,6 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
))
}
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
// so if there's an error when parsing the RemoteAPIVersion, it's handled as
// equivalent to an API version < 2.3.
currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
if !op.PlanRefresh {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Planning without refresh is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -refresh=false option for `+
`remote plans.`,
b.hostname,
),
))
}
}
if op.PlanMode == plans.RefreshOnlyMode {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Refresh-only mode is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support -refresh-only mode for `+
`remote plans.`,
b.hostname,
),
))
}
}
if len(op.ForceReplace) != 0 {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Planning resource replacements is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -replace option for `+
`remote plans.`,
b.hostname,
),
))
}
}
if len(op.Targets) != 0 {
desiredAPIVersion, _ := version.NewVersion("2.3")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Resource targeting is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -target option for `+
`remote plans.`,
b.hostname,
),
))
}
}
// Return if there are any errors.
if diags.HasErrors() {
return nil, diags.Err()

@ -321,38 +321,6 @@ func TestCloud_applyWithoutRefresh(t *testing.T) {
}
}
func TestCloud_applyWithoutRefreshIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
op.PlanRefresh = false
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Planning without refresh is not supported") {
t.Fatalf("expected a not supported error, got: %v", errOutput)
}
}
func TestCloud_applyWithRefreshOnly(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -390,38 +358,6 @@ func TestCloud_applyWithRefreshOnly(t *testing.T) {
}
}
func TestCloud_applyWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
op.PlanMode = plans.RefreshOnlyMode
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Refresh-only mode is not supported") {
t.Fatalf("expected a not supported error, got: %v", errOutput)
}
}
func TestCloud_applyWithTarget(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -461,42 +397,6 @@ func TestCloud_applyWithTarget(t *testing.T) {
}
}
func TestCloud_applyWithTargetIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
// Set the tfe client's RemoteAPIVersion to an empty string, to mimic
// API versions prior to 2.3.
b.client.SetFakeRemoteAPIVersion("")
addr, _ := addrs.ParseAbsResourceStr("null_resource.foo")
op.Targets = []addrs.Targetable{addr}
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Resource targeting is not supported") {
t.Fatalf("expected a targeting error, got: %v", errOutput)
}
}
func TestCloud_applyWithReplace(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -536,40 +436,6 @@ func TestCloud_applyWithReplace(t *testing.T) {
}
}
func TestCloud_applyWithReplaceIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo")
op.ForceReplace = []addrs.AbsResourceInstance{addr}
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Planning resource replacements is not supported") {
t.Fatalf("expected a not supported error, got: %v", errOutput)
}
}
func TestCloud_applyWithVariables(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

@ -15,7 +15,6 @@ import (
"time"
tfe "github.com/hashicorp/go-tfe"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -93,75 +92,6 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation
))
}
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
// so if there's an error when parsing the RemoteAPIVersion, it's handled as
// equivalent to an API version < 2.3.
currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
if len(op.Targets) != 0 {
desiredAPIVersion, _ := version.NewVersion("2.3")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Resource targeting is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -target option for `+
`remote plans.`,
b.hostname,
),
))
}
}
if !op.PlanRefresh {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Planning without refresh is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -refresh=false option for `+
`remote plans.`,
b.hostname,
),
))
}
}
if len(op.ForceReplace) != 0 {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Planning resource replacements is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support the -replace option for `+
`remote plans.`,
b.hostname,
),
))
}
}
if op.PlanMode == plans.RefreshOnlyMode {
desiredAPIVersion, _ := version.NewVersion("2.4")
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Refresh-only mode is not supported",
fmt.Sprintf(
`The Terraform Enterprise installation at %s does not support -refresh-only mode for `+
`remote plans.`,
b.hostname,
),
))
}
}
// Return if there are any errors.
if diags.HasErrors() {
return nil, diags.Err()

@ -324,38 +324,6 @@ func TestCloud_planWithoutRefresh(t *testing.T) {
}
}
func TestCloud_planWithoutRefreshIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
op.PlanRefresh = false
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Planning without refresh is not supported") {
t.Fatalf("expected not supported error, got: %v", errOutput)
}
}
func TestCloud_planWithRefreshOnly(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -393,38 +361,6 @@ func TestCloud_planWithRefreshOnly(t *testing.T) {
}
}
func TestCloud_planWithRefreshOnlyIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
op.PlanMode = plans.RefreshOnlyMode
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Refresh-only mode is not supported") {
t.Fatalf("expected not supported error, got: %v", errOutput)
}
}
func TestCloud_planWithTarget(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -494,42 +430,6 @@ func TestCloud_planWithTarget(t *testing.T) {
}
}
func TestCloud_planWithTargetIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
// Set the tfe client's RemoteAPIVersion to an empty string, to mimic
// API versions prior to 2.3.
b.client.SetFakeRemoteAPIVersion("")
addr, _ := addrs.ParseAbsResourceStr("null_resource.foo")
op.Targets = []addrs.Targetable{addr}
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Resource targeting is not supported") {
t.Fatalf("expected a targeting error, got: %v", errOutput)
}
}
func TestCloud_planWithReplace(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -569,40 +469,6 @@ func TestCloud_planWithReplace(t *testing.T) {
}
}
func TestCloud_planWithReplaceIncompatibleAPIVersion(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
b.client.SetFakeRemoteAPIVersion("2.3")
addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo")
op.ForceReplace = []addrs.AbsResourceInstance{addr}
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Planning resource replacements is not supported") {
t.Fatalf("expected not supported error, got: %v", errOutput)
}
}
func TestCloud_planWithVariables(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

@ -3,6 +3,7 @@ package cloud
import (
"context"
"fmt"
"net/http"
"os"
"reflect"
"strings"
@ -311,6 +312,43 @@ func TestCloud_config(t *testing.T) {
}
}
func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) {
config := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"prefix": cty.NullVal(cty.String),
"tags": cty.SetVal(
[]cty.Value{
cty.StringVal("billing"),
},
),
}),
})
handlers := map[string]func(http.ResponseWriter, *http.Request){
"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("TFP-API-Version", "2.4")
},
}
s := testServerWithHandlers(handlers)
b := New(testDisco(s))
confDiags := b.Configure(config)
if confDiags.Err() == nil {
t.Fatalf("expected configure to error")
}
expected := "The 'cloud' option requires Terraform Enterprise v202201-1 or later."
if !strings.Contains(confDiags.Err().Error(), expected) {
t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error())
}
}
func TestCloud_setConfigurationFields(t *testing.T) {
originalForceBackendEnv := os.Getenv("TF_FORCE_LOCAL_BACKEND")

@ -144,13 +144,13 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) {
// Configure the backend so the client is created.
newObj, valDiags := b.PrepareConfig(obj)
if len(valDiags) != 0 {
t.Fatal(valDiags.ErrWithWarnings())
t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings())
}
obj = newObj
confDiags := b.Configure(obj)
if len(confDiags) != 0 {
t.Fatal(confDiags.ErrWithWarnings())
t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings())
}
// Get a new mock client.
@ -215,22 +215,42 @@ func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced {
return b
}
// testServer returns a *httptest.Server used for local testing.
// testServer returns a started *httptest.Server used for local testing with the default set of
// request handlers.
func testServer(t *testing.T) *httptest.Server {
return testServerWithHandlers(testDefaultRequestHandlers)
}
// testServerWithHandlers returns a started *httptest.Server with the given set of request handlers
// overriding any default request handlers (testDefaultRequestHandlers).
func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server {
mux := http.NewServeMux()
for route, handler := range handlers {
mux.HandleFunc(route, handler)
}
for route, handler := range testDefaultRequestHandlers {
if handlers[route] == nil {
mux.HandleFunc(route, handler)
}
}
return httptest.NewServer(mux)
}
// testDefaultRequestHandlers is a map of request handlers intended to be used in a request
// multiplexer for a test server. A caller may use testServerWithHandlers to start a server with
// this base set of routes, and override a particular route for whatever edge case is being tested.
var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){
// Respond to service discovery calls.
mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) {
"/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{
"state.v2": "/api/v2/",
"tfe.v2.1": "/api/v2/",
"versions.v1": "/v1/versions/"
"tfe.v2": "/api/v2/",
}`)
})
},
// Respond to service version constraints calls.
mux.HandleFunc("/v1/versions/", func(w http.ResponseWriter, r *http.Request) {
"/v1/versions/": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, fmt.Sprintf(`{
"service": "%s",
@ -238,16 +258,16 @@ func testServer(t *testing.T) *httptest.Server {
"minimum": "0.1.0",
"maximum": "10.0.0"
}`, path.Base(r.URL.Path)))
})
},
// Respond to pings to get the API version header.
mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) {
"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("TFP-API-Version", "2.4")
})
w.Header().Set("TFP-API-Version", "2.5")
},
// Respond to the initial query to read the hashicorp org entitlements.
mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) {
"/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
@ -263,10 +283,10 @@ func testServer(t *testing.T) *httptest.Server {
}
}
}`)
})
},
// Respond to the initial query to read the no-operations org entitlements.
mux.HandleFunc("/api/v2/organizations/no-operations/entitlement-set", func(w http.ResponseWriter, r *http.Request) {
"/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
@ -282,11 +302,11 @@ func testServer(t *testing.T) *httptest.Server {
}
}
}`)
})
},
// All tests that are assumed to pass will use the hashicorp organization,
// so for all other organization requests we will return a 404.
mux.HandleFunc("/api/v2/organizations/", func(w http.ResponseWriter, r *http.Request) {
"/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
io.WriteString(w, `{
"errors": [
@ -296,18 +316,14 @@ func testServer(t *testing.T) *httptest.Server {
}
]
}`)
})
return httptest.NewServer(mux)
},
}
// testDisco returns a *disco.Disco mapping app.terraform.io and
// localhost to a local test server.
func testDisco(s *httptest.Server) *disco.Disco {
services := map[string]interface{}{
"state.v2": fmt.Sprintf("%s/api/v2/", s.URL),
"tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL),
"versions.v1": fmt.Sprintf("%s/v1/versions/", s.URL),
"tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL),
}
d := disco.NewWithCredentialsSource(credsSrc)
d.SetUserAgent(httpclient.TerraformUserAgent(version.String()))

@ -0,0 +1,12 @@
package cloud
// This simple map exists to translate TFP-API-Version strings to the TFE release where it was
// introduced, to provide actionable feedback on features that may be unsupported by the TFE
// installation but present in this version of Terraform.
//
// The cloud package here, introduced in Terraform 1.1.0, requires a minimum of 2.5 (v202201-1)
// The TFP-API-Version header that this refers to was introduced in 2.3 (v202006-1), so an absent
// header can be considered < 2.3.
var apiToMinimumTFEVersion = map[string]string{
"2.5": "v202201-1",
}
Loading…
Cancel
Save