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