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