diff --git a/backend/remote/backend.go b/backend/remote/backend.go index 97b2a148ee..6179864065 100644 --- a/backend/remote/backend.go +++ b/backend/remote/backend.go @@ -32,7 +32,7 @@ import ( const ( defaultHostname = "app.terraform.io" defaultParallelism = 10 - serviceID = "tfe.v2" + tfeServiceID = "tfe.v2.1" ) // Remote is an implementation of EnhancedBackend that performs all @@ -141,15 +141,13 @@ func (b *Remote) ConfigSchema() *configschema.Block { func (b *Remote) ValidateConfig(obj cty.Value) tfdiags.Diagnostics { var diags tfdiags.Diagnostics - if val := obj.GetAttr("organization"); !val.IsNull() { - if val.AsString() == "" { - diags = diags.Append(tfdiags.AttributeValue( - tfdiags.Error, - "Invalid organization value", - `The "organization" attribute value must not be empty.`, - cty.Path{cty.GetAttrStep{Name: "organization"}}, - )) - } + if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid organization value", + `The "organization" attribute value must not be empty.`, + cty.Path{cty.GetAttrStep{Name: "organization"}}, + )) } var name, prefix string @@ -219,9 +217,7 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, strings.ToUpper(err.Error()[:1])+err.Error()[1:], - `If you are sure the hostname is correct, this could also indicate SSL `+ - `verification issues. Please use "openssl s_client -connect " to `+ - `identify any certificate or certificate chain issues.`, + "", // no description is needed here, the error is clear cty.Path{cty.GetAttrStep{Name: "hostname"}}, )) return diags @@ -234,9 +230,7 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, strings.ToUpper(err.Error()[:1])+err.Error()[1:], - `If you are sure the hostname is correct, this could also indicate SSL `+ - `verification issues. Please use "openssl s_client -connect " to `+ - `identify any certificate or certificate chain issues.`, + "", // no description is needed here, the error is clear cty.Path{cty.GetAttrStep{Name: "hostname"}}, )) return diags @@ -247,6 +241,19 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { } } + // Return an error if we still don't have a token at this point. + if token == "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required token could not be found", + fmt.Sprintf( + "Make sure you configured a credentials block for %s in your CLI Config File.", + b.hostname, + ), + )) + return diags + } + cfg := &tfe.Config{ Address: service.String(), BasePath: service.Path, @@ -302,7 +309,7 @@ func (b *Remote) discover(hostname string) (*url.URL, error) { if err != nil { return nil, err } - service, err := b.services.DiscoverServiceURL(host, serviceID) + service, err := b.services.DiscoverServiceURL(host, tfeServiceID) if err != nil { return nil, err } @@ -473,7 +480,27 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend // Retrieve the workspace for this operation. w, err := b.client.Workspaces.Read(ctx, b.organization, name) if err != nil { - return nil, generalError("Failed to retrieve workspace", err) + switch err { + case context.Canceled: + return nil, err + case tfe.ErrResourceNotFound: + return nil, fmt.Errorf( + "workspace %s not found\n\n"+ + "The configured \"remote\" backend returns '404 Not Found' errors for resources\n"+ + "that do not exist, as well as for resources that a user doesn't have access\n"+ + "to. When the resource does exists, please check the rights for the used token.", + name, + ) + default: + return nil, fmt.Errorf( + "%s\n\n"+ + "The configured \"remote\" backend encountered an unexpected error. Sometimes\n"+ + "this is caused by network connection problems, in which case you could retr\n"+ + "the command. If the issue persists please open a support ticket to get help\n"+ + "resolving the problem.", + err, + ) + } } // Check if we need to use the local backend to run the operation. @@ -493,9 +520,7 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend f = b.opApply default: return nil, fmt.Errorf( - "\n\nThe \"remote\" backend does not support the %q operation.\n"+ - "Please use the remote backend web UI for running this operation:\n"+ - "https://%s/app/%s/%s", op.Type, b.hostname, b.organization, op.Workspace) + "\n\nThe \"remote\" backend does not support the %q operation.", op.Type) } // Lock diff --git a/backend/remote/backend_test.go b/backend/remote/backend_test.go index 8aa888cc0a..5e8d8ec5d5 100644 --- a/backend/remote/backend_test.go +++ b/backend/remote/backend_test.go @@ -119,8 +119,8 @@ func TestRemote_config(t *testing.T) { // Configure confDiags := b.Configure(tc.config) - if (confDiags.Err() == nil && tc.confErr != "") || - (confDiags.Err() != nil && !strings.Contains(confDiags.Err().Error(), tc.confErr)) { + if (confDiags.Err() != nil || tc.confErr != "") && + (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) { t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err()) } } diff --git a/backend/remote/testing.go b/backend/remote/testing.go index de2d26866b..385d2eaf2d 100644 --- a/backend/remote/testing.go +++ b/backend/remote/testing.go @@ -181,7 +181,7 @@ func testServer(t *testing.T) *httptest.Server { // Respond to service discovery calls. mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `{"tfe.v2":"/api/v2/"}`) + io.WriteString(w, `{"tfe.v2.1":"/api/v2/"}`) }) // Respond to the initial query to read the hashicorp org entitlements. @@ -243,7 +243,7 @@ func testServer(t *testing.T) *httptest.Server { // localhost to a local test server. func testDisco(s *httptest.Server) *disco.Disco { services := map[string]interface{}{ - "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), + "tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL), } d := disco.NewWithCredentialsSource(credsSrc) diff --git a/go.mod b/go.mod index 4e60085c28..2dedc5d84a 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 // indirect github.com/hashicorp/go-tfe v0.3.4 github.com/hashicorp/go-uuid v1.0.0 - github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 + github.com/hashicorp/go-version v1.0.0 github.com/hashicorp/golang-lru v0.5.0 // indirect github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f github.com/hashicorp/hcl2 v0.0.0-20181208003705-670926858200 diff --git a/go.sum b/go.sum index 46e1873532..cd656f1443 100644 --- a/go.sum +++ b/go.sum @@ -161,14 +161,12 @@ github.com/hashicorp/go-tfe v0.3.4 h1:A9pKjZMDTSGozXf2wQlWhBI7QoxCoas14Xg/TSiEAV github.com/hashicorp/go-tfe v0.3.4/go.mod h1:Vssg8/lwVz+PyJ/nAK97zYmXxxLe28MCIMhKo+rva1o= github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 h1:at4+18LrM8myamuV7/vT6x2s1JNXp2k4PsSbt4I02X4= -github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.0.0 h1:21MVWPKDphxa7ineQQTrCU5brh7OuVVAzGOCnnCPtE8= +github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f h1:UdxlrJz4JOnY8W+DbLISwf2B8WXEolNRA8BGCwI9jws= github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= -github.com/hashicorp/hcl2 v0.0.0-20181206005933-df9794be1f23 h1:RcXTRSKSKCJYXxI7yaOwAH1lYfZIzxhQhW2bFC8hABE= -github.com/hashicorp/hcl2 v0.0.0-20181206005933-df9794be1f23/go.mod h1:ShfpTh661oAaxo7VcNxg0zcZW6jvMa7Moy2oFx7e5dE= github.com/hashicorp/hcl2 v0.0.0-20181208003705-670926858200 h1:F/nGtDwtQsuw7ZHmiLpHsPWNljDC24kiSHSGUnou9sw= github.com/hashicorp/hcl2 v0.0.0-20181208003705-670926858200/go.mod h1:ShfpTh661oAaxo7VcNxg0zcZW6jvMa7Moy2oFx7e5dE= github.com/hashicorp/hil v0.0.0-20170627220502-fa9f258a9250 h1:fooK5IvDL/KIsi4LxF/JH68nVdrBSiGNPhS2JAQjtjo= diff --git a/svchost/disco/disco.go b/svchost/disco/disco.go index 42a2dc4cd5..5de5b71742 100644 --- a/svchost/disco/disco.go +++ b/svchost/disco/disco.go @@ -18,6 +18,7 @@ import ( "time" cleanhttp "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/svchost" "github.com/hashicorp/terraform/svchost/auth" ) @@ -97,14 +98,19 @@ func (d *Disco) ForceHostServices(hostname svchost.Hostname, services map[string if services == nil { services = map[string]interface{}{} } + transport := d.Transport + if transport == nil { + transport = httpTransport + } d.hostCache[hostname] = &Host{ discoURL: &url.URL{ Scheme: "https", Host: string(hostname), Path: discoPath, }, - hostname: hostname.ForDisplay(), - services: services, + hostname: hostname.ForDisplay(), + services: services, + transport: transport, } } @@ -151,13 +157,13 @@ func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) { Path: discoPath, } - t := d.Transport - if t == nil { - t = httpTransport + transport := d.Transport + if transport == nil { + transport = httpTransport } client := &http.Client{ - Transport: t, + Transport: transport, Timeout: discoTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -170,9 +176,12 @@ func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) { } req := &http.Request{ + Header: make(http.Header), Method: "GET", URL: discoURL, } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", httpclient.UserAgentString()) creds, err := d.CredentialsForHost(hostname) if err != nil { @@ -194,8 +203,9 @@ func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) { host := &Host{ // Use the discovery URL from resp.Request in // case the client followed any redirects. - discoURL: resp.Request.URL, - hostname: hostname.ForDisplay(), + discoURL: resp.Request.URL, + hostname: hostname.ForDisplay(), + transport: transport, } // Return the host without any services. diff --git a/svchost/disco/disco_test.go b/svchost/disco/disco_test.go index 95204e6f7d..d5826e835b 100644 --- a/svchost/disco/disco_test.go +++ b/svchost/disco/disco_test.go @@ -353,5 +353,5 @@ func testServer(h func(w http.ResponseWriter, r *http.Request)) (portStr string, server.Close() } - return + return portStr, close } diff --git a/svchost/disco/host.go b/svchost/disco/host.go index 55cc10813a..2100801af6 100644 --- a/svchost/disco/host.go +++ b/svchost/disco/host.go @@ -1,16 +1,37 @@ package disco import ( + "encoding/json" "fmt" + "log" + "net/http" "net/url" + "os" + "strconv" "strings" + "time" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/httpclient" ) +const versionServiceID = "versions.v1" + // Host represents a service discovered host. type Host struct { - discoURL *url.URL - hostname string - services map[string]interface{} + discoURL *url.URL + hostname string + services map[string]interface{} + transport http.RoundTripper +} + +// Constraints represents the version constraints of a service. +type Constraints struct { + Service string `json:"service"` + Product string `json:"product"` + Minimum string `json:"minimum"` + Excluding []string `json:"excluding"` + Maximum string `json:"maximum"` } // ErrServiceNotProvided is returned when the service is not provided. @@ -36,21 +57,34 @@ func (e *ErrVersionNotSupported) Error() string { return fmt.Sprintf("host %s does not support %s version %s", e.hostname, e.service, e.version) } +// ErrNoVersionConstraints is returned when checkpoint was disabled +// or the endpoint to query for version constraints was unavailable. +type ErrNoVersionConstraints struct { + disabled bool +} + +// Error returns a customized error message. +func (e *ErrNoVersionConstraints) Error() string { + if e.disabled { + return "checkpoint disabled" + } + return "unable to contact versions service" +} + // ServiceURL returns the URL associated with the given service identifier, // which should be of the form "servicename.vN". // // A non-nil result is always an absolute URL with a scheme of either HTTPS // or HTTP. func (h *Host) ServiceURL(id string) (*url.URL, error) { - parts := strings.SplitN(id, ".", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("Invalid service ID format (i.e. service.vN): %s", id) + svc, ver, err := parseServiceID(id) + if err != nil { + return nil, err } - service, version := parts[0], parts[1] // No services supported for an empty Host. if h == nil || h.services == nil { - return nil, &ErrServiceNotProvided{hostname: "", service: service} + return nil, &ErrServiceNotProvided{hostname: "", service: svc} } urlStr, ok := h.services[id].(string) @@ -58,17 +92,17 @@ func (h *Host) ServiceURL(id string) (*url.URL, error) { // See if we have a matching service as that would indicate // the service is supported, but not the requested version. for serviceID := range h.services { - if strings.HasPrefix(serviceID, service) { + if strings.HasPrefix(serviceID, svc+".") { return nil, &ErrVersionNotSupported{ hostname: h.hostname, - service: service, - version: version, + service: svc, + version: ver.Original(), } } } - // No discovered services match the requested service ID. - return nil, &ErrServiceNotProvided{hostname: h.hostname, service: service} + // No discovered services match the requested service. + return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} } u, err := url.Parse(urlStr) @@ -93,3 +127,132 @@ func (h *Host) ServiceURL(id string) (*url.URL, error) { return h.discoURL.ResolveReference(u), nil } + +// VersionConstraints returns the contraints for a given service identifier +// (which should be of the form "servicename.vN") and product. +// +// When an exact (service and version) match is found, the constraints for +// that service are returned. +// +// When the requested version is not provided but the service is, we will +// search for all alternative versions. If mutliple alternative versions +// are found, the contrains of the latest available version are returned. +// +// When a service is not provided at all an error will be returned instead. +// +// When checkpoint is disabled or when a 404 is returned after making the +// HTTP call, an ErrNoVersionConstraints error will be returned. +func (h *Host) VersionConstraints(id, product string) (*Constraints, error) { + svc, _, err := parseServiceID(id) + if err != nil { + return nil, err + } + + // Return early if checkpoint is disabled. + if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" { + return nil, &ErrNoVersionConstraints{disabled: true} + } + + // No services supported for an empty Host. + if h == nil || h.services == nil { + return nil, &ErrServiceNotProvided{hostname: "", service: svc} + } + + // Try to get the service URL for the version service and + // return early if the service isn't provided by the host. + u, err := h.ServiceURL(versionServiceID) + if err != nil { + return nil, err + } + + // Check if we have an exact (service and version) match. + if _, ok := h.services[id].(string); !ok { + // If we don't have an exact match, we search for all matching + // services and then use the service ID of the latest version. + var services []string + for serviceID := range h.services { + if strings.HasPrefix(serviceID, svc+".") { + services = append(services, serviceID) + } + } + + if len(services) == 0 { + // No discovered services match the requested service. + return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} + } + + // Set id to the latest service ID we found. + var latest *version.Version + for _, serviceID := range services { + if _, ver, err := parseServiceID(serviceID); err == nil { + if latest == nil || latest.LessThan(ver) { + id = serviceID + latest = ver + } + } + } + } + + // Set a default timeout of 1 sec for the versions request (in milliseconds) + timeout := 1000 + if _, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil { + timeout, _ = strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")) + } + + client := &http.Client{ + Transport: h.transport, + Timeout: time.Duration(timeout) * time.Millisecond, + } + + // Prepare the service URL by setting the service and product. + v := u.Query() + v.Set("product", product) + u.Path += id + u.RawQuery = v.Encode() + + // Create a new request. + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, fmt.Errorf("Failed to create version constraints request: %v", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", httpclient.UserAgentString()) + + log.Printf("[DEBUG] Retrieve version constraints for service %s and product %s", id, product) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Failed to request version constraints: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, &ErrNoVersionConstraints{disabled: false} + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Failed to request version constraints: %s", resp.Status) + } + + // Parse the constraints from the response body. + result := &Constraints{} + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return nil, fmt.Errorf("Error parsing version constraints: %v", err) + } + + return result, nil +} + +func parseServiceID(id string) (string, *version.Version, error) { + parts := strings.SplitN(id, ".", 2) + if len(parts) != 2 { + return "", nil, fmt.Errorf("Invalid service ID format (i.e. service.vN): %s", id) + } + + version, err := version.NewVersion(parts[1]) + if err != nil { + return "", nil, fmt.Errorf("Invalid service version: %v", err) + } + + return parts[0], version, nil +} diff --git a/svchost/disco/host_test.go b/svchost/disco/host_test.go index c6a1d8eaf6..5a96aa418c 100644 --- a/svchost/disco/host_test.go +++ b/svchost/disco/host_test.go @@ -1,7 +1,14 @@ package disco import ( + "fmt" + "net/http" + "net/http/httptest" "net/url" + "os" + "path" + "reflect" + "strconv" "strings" "testing" ) @@ -61,3 +68,223 @@ func TestHostServiceURL(t *testing.T) { }) } } + +func TestVersionConstrains(t *testing.T) { + baseURL, _ := url.Parse("https://example.com/disco/foo.json") + + t.Run("exact service version is provided", func(t *testing.T) { + portStr, close := testVersionsServer(func(w http.ResponseWriter, r *http.Request) { + resp := []byte(` +{ + "service": "%s", + "product": "%s", + "minimum": "0.11.8", + "maximum": "0.12.0" +}`) + // Add the requested service and product to the response. + service := path.Base(r.URL.Path) + product := r.URL.Query().Get("product") + resp = []byte(fmt.Sprintf(string(resp), service, product)) + + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Length", strconv.Itoa(len(resp))) + w.Write(resp) + }) + defer close() + + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v1": "/api/v1/", + "thingy.v2": "/api/v2/", + "versions.v1": "https://localhost" + portStr + "/v1/versions/", + }, + } + + expected := &Constraints{ + Service: "thingy.v1", + Product: "terraform", + Minimum: "0.11.8", + Maximum: "0.12.0", + } + + actual, err := host.VersionConstraints("thingy.v1", "terraform") + if err != nil { + t.Fatalf("unexpected version constraints error: %s", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected %#v, got: %#v", expected, actual) + } + }) + + t.Run("service provided with different versions", func(t *testing.T) { + portStr, close := testVersionsServer(func(w http.ResponseWriter, r *http.Request) { + resp := []byte(` +{ + "service": "%s", + "product": "%s", + "minimum": "0.11.8", + "maximum": "0.12.0" +}`) + // Add the requested service and product to the response. + service := path.Base(r.URL.Path) + product := r.URL.Query().Get("product") + resp = []byte(fmt.Sprintf(string(resp), service, product)) + + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Content-Length", strconv.Itoa(len(resp))) + w.Write(resp) + }) + defer close() + + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v2": "/api/v2/", + "thingy.v3": "/api/v3/", + "versions.v1": "https://localhost" + portStr + "/v1/versions/", + }, + } + + expected := &Constraints{ + Service: "thingy.v3", + Product: "terraform", + Minimum: "0.11.8", + Maximum: "0.12.0", + } + + actual, err := host.VersionConstraints("thingy.v1", "terraform") + if err != nil { + t.Fatalf("unexpected version constraints error: %s", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected %#v, got: %#v", expected, actual) + } + }) + + t.Run("service not provided", func(t *testing.T) { + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "versions.v1": "https://localhost/v1/versions/", + }, + } + + _, err := host.VersionConstraints("thingy.v1", "terraform") + if _, ok := err.(*ErrServiceNotProvided); !ok { + t.Fatalf("expected service not provided error, got: %v", err) + } + }) + + t.Run("versions service returns a 404", func(t *testing.T) { + portStr, close := testVersionsServer(nil) + defer close() + + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v1": "/api/v1/", + "versions.v1": "https://localhost" + portStr + "/v1/non-existent/", + }, + } + + _, err := host.VersionConstraints("thingy.v1", "terraform") + if _, ok := err.(*ErrNoVersionConstraints); !ok { + t.Fatalf("expected service not provided error, got: %v", err) + } + }) + + t.Run("checkpoint is disabled", func(t *testing.T) { + if err := os.Setenv("CHECKPOINT_DISABLE", "1"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Unsetenv("CHECKPOINT_DISABLE") + + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v1": "/api/v1/", + "versions.v1": "https://localhost/v1/versions/", + }, + } + + _, err := host.VersionConstraints("thingy.v1", "terraform") + if _, ok := err.(*ErrNoVersionConstraints); !ok { + t.Fatalf("expected service not provided error, got: %v", err) + } + }) + + t.Run("versions service not discovered", func(t *testing.T) { + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v1": "/api/v1/", + }, + } + + _, err := host.VersionConstraints("thingy.v1", "terraform") + if _, ok := err.(*ErrServiceNotProvided); !ok { + t.Fatalf("expected service not provided error, got: %v", err) + } + }) + + t.Run("versions service version not discovered", func(t *testing.T) { + host := Host{ + discoURL: baseURL, + hostname: "test-server", + transport: httpTransport, + services: map[string]interface{}{ + "thingy.v1": "/api/v1/", + "versions.v2": "https://localhost/v2/versions/", + }, + } + + _, err := host.VersionConstraints("thingy.v1", "terraform") + if _, ok := err.(*ErrVersionNotSupported); !ok { + t.Fatalf("expected service not provided error, got: %v", err) + } + }) +} + +func testVersionsServer(h func(w http.ResponseWriter, r *http.Request)) (portStr string, close func()) { + server := httptest.NewTLSServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // Test server always returns 404 if the URL isn't what we expect + if !strings.HasPrefix(r.URL.Path, "/v1/versions/") { + w.WriteHeader(404) + w.Write([]byte("not found")) + return + } + + // If the URL is correct then the given hander decides the response + h(w, r) + }, + )) + + serverURL, _ := url.Parse(server.URL) + + portStr = serverURL.Port() + if portStr != "" { + portStr = ":" + portStr + } + + close = func() { + server.Close() + } + + return portStr, close +} diff --git a/vendor/github.com/hashicorp/go-version/.travis.yml b/vendor/github.com/hashicorp/go-version/.travis.yml index 3f45b1e8d5..542ca8b7f3 100644 --- a/vendor/github.com/hashicorp/go-version/.travis.yml +++ b/vendor/github.com/hashicorp/go-version/.travis.yml @@ -7,6 +7,7 @@ go: - 1.3 - 1.4 - 1.9 + - "1.10" script: - go test diff --git a/vendor/github.com/hashicorp/go-version/go.mod b/vendor/github.com/hashicorp/go-version/go.mod new file mode 100644 index 0000000000..f5285555fa --- /dev/null +++ b/vendor/github.com/hashicorp/go-version/go.mod @@ -0,0 +1 @@ +module github.com/hashicorp/go-version diff --git a/vendor/github.com/hashicorp/go-version/version.go b/vendor/github.com/hashicorp/go-version/version.go index bee527eb25..4d1e6e2210 100644 --- a/vendor/github.com/hashicorp/go-version/version.go +++ b/vendor/github.com/hashicorp/go-version/version.go @@ -15,7 +15,7 @@ var versionRegexp *regexp.Regexp // The raw regular expression string used for testing the validity // of a version. const VersionRegexpRaw string = `v?([0-9]+(\.[0-9]+)*?)` + - `(-?([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + + `(-([0-9]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)|(-?([A-Za-z\-~]+[0-9A-Za-z\-~]*(\.[0-9A-Za-z\-~]+)*)))?` + `(\+([0-9A-Za-z\-~]+(\.[0-9A-Za-z\-~]+)*))?` + `?` @@ -25,6 +25,7 @@ type Version struct { pre string segments []int64 si int + original string } func init() { @@ -59,11 +60,17 @@ func NewVersion(v string) (*Version, error) { segments = append(segments, 0) } + pre := matches[7] + if pre == "" { + pre = matches[4] + } + return &Version{ - metadata: matches[7], - pre: matches[4], + metadata: matches[10], + pre: pre, segments: segments, si: si, + original: v, }, nil } @@ -301,11 +308,19 @@ func (v *Version) Segments() []int { // for a version "1.2.3-beta", segments will return a slice of // 1, 2, 3. func (v *Version) Segments64() []int64 { - return v.segments + result := make([]int64, len(v.segments)) + copy(result, v.segments) + return result } // String returns the full version string included pre-release // and metadata information. +// +// This value is rebuilt according to the parsed segments and other +// information. Therefore, ambiguities in the version string such as +// prefixed zeroes (1.04.0 => 1.4.0), `v` prefix (v1.0.0 => 1.0.0), and +// missing parts (1.0 => 1.0.0) will be made into a canonicalized form +// as shown in the parenthesized examples. func (v *Version) String() string { var buf bytes.Buffer fmtParts := make([]string, len(v.segments)) @@ -324,3 +339,9 @@ func (v *Version) String() string { return buf.String() } + +// Original returns the original parsed version as-is, including any +// potential whitespace, `v` prefix, etc. +func (v *Version) Original() string { + return v.original +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 637ca14022..191a3fe281 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -331,7 +331,7 @@ github.com/hashicorp/go-slug github.com/hashicorp/go-tfe # github.com/hashicorp/go-uuid v1.0.0 github.com/hashicorp/go-uuid -# github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 +# github.com/hashicorp/go-version v1.0.0 github.com/hashicorp/go-version # github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f github.com/hashicorp/hcl diff --git a/website/docs/backends/types/remote.html.md b/website/docs/backends/types/remote.html.md index 25f08b1ec4..895d92d45c 100644 --- a/website/docs/backends/types/remote.html.md +++ b/website/docs/backends/types/remote.html.md @@ -28,8 +28,12 @@ Terraform Enterprise (version v201809-1 or newer). Currently the remote backend supports the following Terraform commands: - `apply` +- `console` +- `destroy` (requires manually setting `CONFIRM_DESTROY=1` on the workspace) - `fmt` - `get` +- `graph` +- `import` - `init` - `output` - `plan`