github.com/kcburge/terraform@v0.11.12-beta1/svchost/disco/disco.go (about)

     1  // Package disco handles Terraform's remote service discovery protocol.
     2  //
     3  // This protocol allows mapping from a service hostname, as produced by the
     4  // svchost package, to a set of services supported by that host and the
     5  // endpoint information for each supported service.
     6  package disco
     7  
     8  import (
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"io/ioutil"
    14  	"log"
    15  	"mime"
    16  	"net/http"
    17  	"net/url"
    18  	"time"
    19  
    20  	cleanhttp "github.com/hashicorp/go-cleanhttp"
    21  	"github.com/hashicorp/terraform/httpclient"
    22  	"github.com/hashicorp/terraform/svchost"
    23  	"github.com/hashicorp/terraform/svchost/auth"
    24  )
    25  
    26  const (
    27  	// Fixed path to the discovery manifest.
    28  	discoPath = "/.well-known/terraform.json"
    29  
    30  	// Arbitrary-but-small number to prevent runaway redirect loops.
    31  	maxRedirects = 3
    32  
    33  	// Arbitrary-but-small time limit to prevent UI "hangs" during discovery.
    34  	discoTimeout = 11 * time.Second
    35  
    36  	// 1MB - to prevent abusive services from using loads of our memory.
    37  	maxDiscoDocBytes = 1 * 1024 * 1024
    38  )
    39  
    40  // httpTransport is overridden during tests, to skip TLS verification.
    41  var httpTransport = cleanhttp.DefaultPooledTransport()
    42  
    43  // Disco is the main type in this package, which allows discovery on given
    44  // hostnames and caches the results by hostname to avoid repeated requests
    45  // for the same information.
    46  type Disco struct {
    47  	hostCache map[svchost.Hostname]*Host
    48  	credsSrc  auth.CredentialsSource
    49  
    50  	// Transport is a custom http.RoundTripper to use.
    51  	Transport http.RoundTripper
    52  }
    53  
    54  // New returns a new initialized discovery object.
    55  func New() *Disco {
    56  	return NewWithCredentialsSource(nil)
    57  }
    58  
    59  // NewWithCredentialsSource returns a new discovery object initialized with
    60  // the given credentials source.
    61  func NewWithCredentialsSource(credsSrc auth.CredentialsSource) *Disco {
    62  	return &Disco{
    63  		hostCache: make(map[svchost.Hostname]*Host),
    64  		credsSrc:  credsSrc,
    65  		Transport: httpTransport,
    66  	}
    67  }
    68  
    69  // SetCredentialsSource provides a credentials source that will be used to
    70  // add credentials to outgoing discovery requests, where available.
    71  //
    72  // If this method is never called, no outgoing discovery requests will have
    73  // credentials.
    74  func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
    75  	d.credsSrc = src
    76  }
    77  
    78  // CredentialsForHost returns a non-nil HostCredentials if the embedded source has
    79  // credentials available for the host, and a nil HostCredentials if it does not.
    80  func (d *Disco) CredentialsForHost(hostname svchost.Hostname) (auth.HostCredentials, error) {
    81  	if d.credsSrc == nil {
    82  		return nil, nil
    83  	}
    84  	return d.credsSrc.ForHost(hostname)
    85  }
    86  
    87  // ForceHostServices provides a pre-defined set of services for a given
    88  // host, which prevents the receiver from attempting network-based discovery
    89  // for the given host. Instead, the given services map will be returned
    90  // verbatim.
    91  //
    92  // When providing "forced" services, any relative URLs are resolved against
    93  // the initial discovery URL that would have been used for network-based
    94  // discovery, yielding the same results as if the given map were published
    95  // at the host's default discovery URL, though using absolute URLs is strongly
    96  // recommended to make the configured behavior more explicit.
    97  func (d *Disco) ForceHostServices(hostname svchost.Hostname, services map[string]interface{}) {
    98  	if services == nil {
    99  		services = map[string]interface{}{}
   100  	}
   101  
   102  	d.hostCache[hostname] = &Host{
   103  		discoURL: &url.URL{
   104  			Scheme: "https",
   105  			Host:   string(hostname),
   106  			Path:   discoPath,
   107  		},
   108  		hostname:  hostname.ForDisplay(),
   109  		services:  services,
   110  		transport: d.Transport,
   111  	}
   112  }
   113  
   114  // Discover runs the discovery protocol against the given hostname (which must
   115  // already have been validated and prepared with svchost.ForComparison) and
   116  // returns an object describing the services available at that host.
   117  //
   118  // If a given hostname supports no Terraform services at all, a non-nil but
   119  // empty Host object is returned. When giving feedback to the end user about
   120  // such situations, we say "host <name> does not provide a <service> service",
   121  // regardless of whether that is due to that service specifically being absent
   122  // or due to the host not providing Terraform services at all, since we don't
   123  // wish to expose the detail of whole-host discovery to an end-user.
   124  func (d *Disco) Discover(hostname svchost.Hostname) (*Host, error) {
   125  	if host, cached := d.hostCache[hostname]; cached {
   126  		return host, nil
   127  	}
   128  
   129  	host, err := d.discover(hostname)
   130  	if err != nil {
   131  		return nil, err
   132  	}
   133  	d.hostCache[hostname] = host
   134  
   135  	return host, nil
   136  }
   137  
   138  // DiscoverServiceURL is a convenience wrapper for discovery on a given
   139  // hostname and then looking up a particular service in the result.
   140  func (d *Disco) DiscoverServiceURL(hostname svchost.Hostname, serviceID string) (*url.URL, error) {
   141  	host, err := d.Discover(hostname)
   142  	if err != nil {
   143  		return nil, err
   144  	}
   145  	return host.ServiceURL(serviceID)
   146  }
   147  
   148  // discover implements the actual discovery process, with its result cached
   149  // by the public-facing Discover method.
   150  func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) {
   151  	discoURL := &url.URL{
   152  		Scheme: "https",
   153  		Host:   hostname.String(),
   154  		Path:   discoPath,
   155  	}
   156  
   157  	client := &http.Client{
   158  		Transport: d.Transport,
   159  		Timeout:   discoTimeout,
   160  
   161  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
   162  			log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
   163  			if len(via) > maxRedirects {
   164  				return errors.New("too many redirects") // this error will never actually be seen
   165  			}
   166  			return nil
   167  		},
   168  	}
   169  
   170  	req := &http.Request{
   171  		Header: make(http.Header),
   172  		Method: "GET",
   173  		URL:    discoURL,
   174  	}
   175  	req.Header.Set("Accept", "application/json")
   176  	req.Header.Set("User-Agent", httpclient.UserAgentString())
   177  
   178  	creds, err := d.CredentialsForHost(hostname)
   179  	if err != nil {
   180  		log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname, err)
   181  	}
   182  	if creds != nil {
   183  		// Update the request to include credentials.
   184  		creds.PrepareRequest(req)
   185  	}
   186  
   187  	log.Printf("[DEBUG] Service discovery for %s at %s", hostname, discoURL)
   188  
   189  	resp, err := client.Do(req)
   190  	if err != nil {
   191  		return nil, fmt.Errorf("Failed to request discovery document: %v", err)
   192  	}
   193  	defer resp.Body.Close()
   194  
   195  	host := &Host{
   196  		// Use the discovery URL from resp.Request in
   197  		// case the client followed any redirects.
   198  		discoURL:  resp.Request.URL,
   199  		hostname:  hostname.ForDisplay(),
   200  		transport: d.Transport,
   201  	}
   202  
   203  	// Return the host without any services.
   204  	if resp.StatusCode == 404 {
   205  		return host, nil
   206  	}
   207  
   208  	if resp.StatusCode != 200 {
   209  		return nil, fmt.Errorf("Failed to request discovery document: %s", resp.Status)
   210  	}
   211  
   212  	contentType := resp.Header.Get("Content-Type")
   213  	mediaType, _, err := mime.ParseMediaType(contentType)
   214  	if err != nil {
   215  		return nil, fmt.Errorf("Discovery URL has a malformed Content-Type %q", contentType)
   216  	}
   217  	if mediaType != "application/json" {
   218  		return nil, fmt.Errorf("Discovery URL returned an unsupported Content-Type %q", mediaType)
   219  	}
   220  
   221  	// This doesn't catch chunked encoding, because ContentLength is -1 in that case.
   222  	if resp.ContentLength > maxDiscoDocBytes {
   223  		// Size limit here is not a contractual requirement and so we may
   224  		// adjust it over time if we find a different limit is warranted.
   225  		return nil, fmt.Errorf(
   226  			"Discovery doc response is too large (got %d bytes; limit %d)",
   227  			resp.ContentLength, maxDiscoDocBytes,
   228  		)
   229  	}
   230  
   231  	// If the response is using chunked encoding then we can't predict its
   232  	// size, but we'll at least prevent reading the entire thing into memory.
   233  	lr := io.LimitReader(resp.Body, maxDiscoDocBytes)
   234  
   235  	servicesBytes, err := ioutil.ReadAll(lr)
   236  	if err != nil {
   237  		return nil, fmt.Errorf("Error reading discovery document body: %v", err)
   238  	}
   239  
   240  	var services map[string]interface{}
   241  	err = json.Unmarshal(servicesBytes, &services)
   242  	if err != nil {
   243  		return nil, fmt.Errorf("Failed to decode discovery document as a JSON object: %v", err)
   244  	}
   245  	host.services = services
   246  
   247  	return host, nil
   248  }
   249  
   250  // Forget invalidates any cached record of the given hostname. If the host
   251  // has no cache entry then this is a no-op.
   252  func (d *Disco) Forget(hostname svchost.Hostname) {
   253  	delete(d.hostCache, hostname)
   254  }
   255  
   256  // ForgetAll is like Forget, but for all of the hostnames that have cache entries.
   257  func (d *Disco) ForgetAll() {
   258  	d.hostCache = make(map[svchost.Hostname]*Host)
   259  }