Add a method to retrieve version contraints

This commit is contained in:
Sander van Harmelen 2018-12-13 23:12:36 +01:00
parent a4380f7246
commit 268c0f85ce
8 changed files with 476 additions and 47 deletions

View File

@ -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,8 +141,7 @@ 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() == "" {
if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid organization value",
@ -150,7 +149,6 @@ func (b *Remote) ValidateConfig(obj cty.Value) tfdiags.Diagnostics {
cty.Path{cty.GetAttrStep{Name: "organization"}},
))
}
}
var name, prefix string
if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
@ -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 <HOST>" 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 <HOST>" 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

View File

@ -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())
}
}

View File

@ -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)

View File

@ -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,6 +98,10 @@ 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",
@ -105,6 +110,7 @@ func (d *Disco) ForceHostServices(hostname svchost.Hostname, services map[string
},
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 {
@ -196,6 +205,7 @@ func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) {
// case the client followed any redirects.
discoURL: resp.Request.URL,
hostname: hostname.ForDisplay(),
transport: transport,
}
// Return the host without any services.

View File

@ -353,5 +353,5 @@ func testServer(h func(w http.ResponseWriter, r *http.Request)) (portStr string,
server.Close()
}
return
return portStr, close
}

View File

@ -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{}
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: "<unknown>", service: service}
return nil, &ErrServiceNotProvided{hostname: "<unknown>", 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: "<unknown>", 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
}

View File

@ -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
}

View File

@ -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`