backend/remote: return detailed incompatibility info

This commit is contained in:
Sander van Harmelen 2018-12-14 19:31:42 +01:00
parent 28f2a76ffd
commit 8f04e93739
3 changed files with 269 additions and 31 deletions

View File

@ -12,6 +12,7 @@ import (
"sync"
tfe "github.com/hashicorp/go-tfe"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/helper/schema"
@ -21,7 +22,7 @@ import (
"github.com/hashicorp/terraform/svchost/disco"
"github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags"
"github.com/hashicorp/terraform/version"
tfversion "github.com/hashicorp/terraform/version"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
@ -211,8 +212,30 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
}
// Discover the service URL for this host to confirm that it provides
// a remote backend API and to discover the required base path.
service, err := b.discover(b.hostname)
// a remote backend API and to get the version constraints.
service, constraints, err := b.discover()
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
strings.ToUpper(err.Error()[:1])+err.Error()[1:],
"", // no description is needed here, the error is clear
cty.Path{cty.GetAttrStep{Name: "hostname"}},
))
}
// Check any retrieved constraints to make sure we are compatible.
if constraints == nil {
diags = diags.Append(b.checkConstraints(constraints))
}
// Return if we have any discovery of version constraints errors.
if diags.HasErrors() {
return diags
}
// Retrieve the token for this host as configured in the credentials
// section of the CLI Config File.
token, err := b.token()
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
@ -223,18 +246,8 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
return diags
}
// Retrieve the token for this host as configured in the credentials
// section of the CLI Config File.
token, err := b.token(b.hostname)
if err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
strings.ToUpper(err.Error()[:1])+err.Error()[1:],
"", // no description is needed here, the error is clear
cty.Path{cty.GetAttrStep{Name: "hostname"}},
))
return diags
}
// Get the token from the config if no token was configured for this
// host in credentials section of the CLI Config File.
if token == "" {
if val := obj.GetAttr("token"); !val.IsNull() {
token = val.AsString()
@ -262,7 +275,7 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
}
// Set the version header to the current version.
cfg.Headers.Set(version.Header, version.Version)
cfg.Headers.Set(tfversion.Header, tfversion.Version)
// Create the remote backend API client.
b.client, err = tfe.NewClient(cfg)
@ -303,30 +316,146 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
return diags
}
// discover the remote backend API service URL and token.
func (b *Remote) discover(hostname string) (*url.URL, error) {
host, err := svchost.ForComparison(hostname)
// discover the remote backend API service URL and version constraints.
func (b *Remote) discover() (*url.URL, *disco.Constraints, error) {
hostname, err := svchost.ForComparison(b.hostname)
if err != nil {
return nil, err
return nil, nil, err
}
service, err := b.services.DiscoverServiceURL(host, tfeServiceID)
host, err := b.services.Discover(hostname)
if err != nil {
return nil, err
return nil, nil, err
}
return service, nil
service, err := host.ServiceURL(tfeServiceID)
// Return the error, unless its a disco.ErrVersionNotSupported error.
if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil {
return nil, nil, err
}
// Return early if we are a development build.
if tfversion.Prerelease == "dev" {
return service, nil, err
}
// We purposefully ignore the error and return the previous error, as
// checking for version constraints is considered optional.
constraints, _ := host.VersionConstraints(tfeServiceID, "terraform")
return service, constraints, err
}
// checkConstraints checks service version constrains against our own
// version and returns rich and informational diagnostics in case any
// incompatibilities are detected.
func (b *Remote) checkConstraints(c *disco.Constraints) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if c == nil || c.Minimum == "" || c.Maximum == "" {
return diags
}
// Generate a parsable constraints string.
excluding := ""
if len(c.Excluding) > 0 {
excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != "))
}
constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum)
// Create the constraints to check against.
constraints, err := version.NewConstraint(constStr)
if err != nil {
return diags.Append(checkConstraintsWarning(err))
}
// Create the version to check.
v, err := version.NewVersion(tfversion.String())
if err != nil {
return diags.Append(checkConstraintsWarning(err))
}
// Return if we satisfy all constraints.
if constraints.Check(v) {
return diags
}
// Find out what action (upgrade/downgrade) we should advice.
minimum, err := version.NewVersion(c.Minimum)
if err != nil {
return diags.Append(checkConstraintsWarning(err))
}
maximum, err := version.NewVersion(c.Maximum)
if err != nil {
return diags.Append(checkConstraintsWarning(err))
}
var action, toVersion string
var excludes []*version.Version
switch {
case minimum.GreaterThan(v):
action = "upgrade"
toVersion = ">= " + minimum.String()
case maximum.LessThan(v):
action = "downgrade"
toVersion = "<= " + maximum.String()
case len(c.Excluding) > 0:
for _, exclude := range c.Excluding {
v, err := version.NewVersion(exclude)
if err != nil {
return diags.Append(checkConstraintsWarning(err))
}
excludes = append(excludes, v)
}
// Sort all the excludes.
sort.Sort(version.Collection(excludes))
// Get the latest excluded version.
action = "upgrade"
toVersion = "> " + excludes[len(excludes)-1].String()
}
switch {
case len(excludes) == 1:
excluding = fmt.Sprintf(", excluding version %s", excludes[0].String())
case len(excludes) > 1:
var vs []string
for _, v := range excludes {
vs = append(vs, v.String())
}
excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", "))
default:
excluding = ""
}
summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String())
details := fmt.Sprintf(
"The configured Terraform Enterprise backend is compatible with Terraform "+
"versions >= %s, < %s%s.", c.Minimum, c.Maximum, excluding,
)
if action != "" && toVersion != "" {
summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion)
details += fmt.Sprintf(" Please %s to a supported version and try again.", action)
}
// Return the customized and informational error message.
return diags.Append(tfdiags.Sourceless(tfdiags.Error, summary, details))
}
// token returns the token for this host as configured in the credentials
// section of the CLI Config File. If no token was configured, an empty
// string will be returned instead.
func (b *Remote) token(hostname string) (string, error) {
host, err := svchost.ForComparison(hostname)
func (b *Remote) token() (string, error) {
hostname, err := svchost.ForComparison(b.hostname)
if err != nil {
return "", err
}
creds, err := b.services.CredentialsForHost(host)
creds, err := b.services.CredentialsForHost(hostname)
if err != nil {
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err)
return "", nil
}
if creds != nil {
@ -444,8 +573,8 @@ func (b *Remote) StateMgr(name string) (state.State, error) {
// We only set the Terraform Version for the new workspace if this is
// a release candidate or a final release.
if version.Prerelease == "" || strings.HasPrefix(version.Prerelease, "rc") {
options.TerraformVersion = tfe.String(version.String())
if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") {
options.TerraformVersion = tfe.String(tfversion.String())
}
workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options)
@ -709,6 +838,15 @@ func generalError(msg string, err error) error {
}
}
func checkConstraintsWarning(err error) tfdiags.Diagnostic {
return tfdiags.Sourceless(
tfdiags.Warning,
fmt.Sprintf("Failed to check version constraints: %v", err),
"Checking version constraints is considered optional, but this is an"+
"unexpected error which should be reported.",
)
}
const operationCanceled = `
[reset][red]The remote operation was successfully cancelled.[reset]
`

View File

@ -6,6 +6,8 @@ import (
"testing"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/svchost/disco"
"github.com/hashicorp/terraform/version"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/hashicorp/terraform/backend/local"
@ -241,3 +243,95 @@ func TestRemote_addAndRemoveWorkspacesNoDefault(t *testing.T) {
t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states)
}
}
func TestRemote_checkConstraints(t *testing.T) {
b := testBackendDefault(t)
cases := map[string]struct {
constraints *disco.Constraints
prerelease string
version string
result string
}{
"compatible version": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Maximum: "0.11.11",
},
version: "0.11.1",
result: "",
},
"version too old": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Maximum: "0.11.11",
},
version: "0.10.1",
result: "upgrade Terraform to >= 0.11.0",
},
"version too new": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Maximum: "0.11.11",
},
version: "0.12.0",
result: "downgrade Terraform to <= 0.11.11",
},
"version excluded - ordered": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Excluding: []string{"0.11.7", "0.11.8"},
Maximum: "0.11.11",
},
version: "0.11.7",
result: "upgrade Terraform to > 0.11.8",
},
"version excluded - unordered": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Excluding: []string{"0.11.8", "0.11.6"},
Maximum: "0.11.11",
},
version: "0.11.6",
result: "upgrade Terraform to > 0.11.8",
},
"list versions": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Maximum: "0.11.11",
},
version: "0.10.1",
result: "versions >= 0.11.0, < 0.11.11.",
},
"list exclusion": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Excluding: []string{"0.11.6"},
Maximum: "0.11.11",
},
version: "0.11.6",
result: "excluding version 0.11.6.",
},
"list exclusions": {
constraints: &disco.Constraints{
Minimum: "0.11.0",
Excluding: []string{"0.11.8", "0.11.6"},
Maximum: "0.11.11",
},
version: "0.11.6",
result: "excluding versions 0.11.6, 0.11.8.",
},
}
for name, tc := range cases {
version.Prerelease = tc.prerelease
version.Version = tc.version
// Check the constraints.
diags := b.checkConstraints(tc.constraints)
if (diags.Err() != nil || tc.result != "") &&
(diags.Err() == nil || !strings.Contains(diags.Err().Error(), tc.result)) {
t.Fatalf("%s: unexpected constraints result: %v", name, diags.Err())
}
}
}

View File

@ -42,6 +42,9 @@ type ErrServiceNotProvided struct {
// Error returns a customized error message.
func (e *ErrServiceNotProvided) Error() string {
if e.hostname == "" {
return fmt.Sprintf("host does not provide a %s service", e.service)
}
return fmt.Sprintf("host %s does not provide a %s service", e.hostname, e.service)
}
@ -54,6 +57,9 @@ type ErrVersionNotSupported struct {
// Error returns a customized error message.
func (e *ErrVersionNotSupported) Error() string {
if e.hostname == "" {
return fmt.Sprintf("host does not support %s version %s", e.service, e.version)
}
return fmt.Sprintf("host %s does not support %s version %s", e.hostname, e.service, e.version)
}
@ -84,7 +90,7 @@ func (h *Host) ServiceURL(id string) (*url.URL, error) {
// No services supported for an empty Host.
if h == nil || h.services == nil {
return nil, &ErrServiceNotProvided{hostname: "<unknown>", service: svc}
return nil, &ErrServiceNotProvided{service: svc}
}
urlStr, ok := h.services[id].(string)
@ -155,7 +161,7 @@ func (h *Host) VersionConstraints(id, product string) (*Constraints, error) {
// No services supported for an empty Host.
if h == nil || h.services == nil {
return nil, &ErrServiceNotProvided{hostname: "<unknown>", service: svc}
return nil, &ErrServiceNotProvided{service: svc}
}
// Try to get the service URL for the version service and