260 lines
8.1 KiB
Go
260 lines
8.1 KiB
Go
// Package disco handles Terraform's remote service discovery protocol.
|
|
//
|
|
// This protocol allows mapping from a service hostname, as produced by the
|
|
// svchost package, to a set of services supported by that host and the
|
|
// endpoint information for each supported service.
|
|
package disco
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
cleanhttp "github.com/hashicorp/go-cleanhttp"
|
|
"github.com/hashicorp/terraform/httpclient"
|
|
"github.com/hashicorp/terraform/svchost"
|
|
"github.com/hashicorp/terraform/svchost/auth"
|
|
)
|
|
|
|
const (
|
|
// Fixed path to the discovery manifest.
|
|
discoPath = "/.well-known/terraform.json"
|
|
|
|
// Arbitrary-but-small number to prevent runaway redirect loops.
|
|
maxRedirects = 3
|
|
|
|
// Arbitrary-but-small time limit to prevent UI "hangs" during discovery.
|
|
discoTimeout = 11 * time.Second
|
|
|
|
// 1MB - to prevent abusive services from using loads of our memory.
|
|
maxDiscoDocBytes = 1 * 1024 * 1024
|
|
)
|
|
|
|
// httpTransport is overridden during tests, to skip TLS verification.
|
|
var httpTransport = cleanhttp.DefaultPooledTransport()
|
|
|
|
// Disco is the main type in this package, which allows discovery on given
|
|
// hostnames and caches the results by hostname to avoid repeated requests
|
|
// for the same information.
|
|
type Disco struct {
|
|
hostCache map[svchost.Hostname]*Host
|
|
credsSrc auth.CredentialsSource
|
|
|
|
// Transport is a custom http.RoundTripper to use.
|
|
Transport http.RoundTripper
|
|
}
|
|
|
|
// New returns a new initialized discovery object.
|
|
func New() *Disco {
|
|
return NewWithCredentialsSource(nil)
|
|
}
|
|
|
|
// NewWithCredentialsSource returns a new discovery object initialized with
|
|
// the given credentials source.
|
|
func NewWithCredentialsSource(credsSrc auth.CredentialsSource) *Disco {
|
|
return &Disco{
|
|
hostCache: make(map[svchost.Hostname]*Host),
|
|
credsSrc: credsSrc,
|
|
Transport: httpTransport,
|
|
}
|
|
}
|
|
|
|
// SetCredentialsSource provides a credentials source that will be used to
|
|
// add credentials to outgoing discovery requests, where available.
|
|
//
|
|
// If this method is never called, no outgoing discovery requests will have
|
|
// credentials.
|
|
func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
|
|
d.credsSrc = src
|
|
}
|
|
|
|
// CredentialsForHost returns a non-nil HostCredentials if the embedded source has
|
|
// credentials available for the host, and a nil HostCredentials if it does not.
|
|
func (d *Disco) CredentialsForHost(hostname svchost.Hostname) (auth.HostCredentials, error) {
|
|
if d.credsSrc == nil {
|
|
return nil, nil
|
|
}
|
|
return d.credsSrc.ForHost(hostname)
|
|
}
|
|
|
|
// ForceHostServices provides a pre-defined set of services for a given
|
|
// host, which prevents the receiver from attempting network-based discovery
|
|
// for the given host. Instead, the given services map will be returned
|
|
// verbatim.
|
|
//
|
|
// When providing "forced" services, any relative URLs are resolved against
|
|
// the initial discovery URL that would have been used for network-based
|
|
// discovery, yielding the same results as if the given map were published
|
|
// at the host's default discovery URL, though using absolute URLs is strongly
|
|
// recommended to make the configured behavior more explicit.
|
|
func (d *Disco) ForceHostServices(hostname svchost.Hostname, services map[string]interface{}) {
|
|
if services == nil {
|
|
services = map[string]interface{}{}
|
|
}
|
|
|
|
d.hostCache[hostname] = &Host{
|
|
discoURL: &url.URL{
|
|
Scheme: "https",
|
|
Host: string(hostname),
|
|
Path: discoPath,
|
|
},
|
|
hostname: hostname.ForDisplay(),
|
|
services: services,
|
|
transport: d.Transport,
|
|
}
|
|
}
|
|
|
|
// Discover runs the discovery protocol against the given hostname (which must
|
|
// already have been validated and prepared with svchost.ForComparison) and
|
|
// returns an object describing the services available at that host.
|
|
//
|
|
// If a given hostname supports no Terraform services at all, a non-nil but
|
|
// empty Host object is returned. When giving feedback to the end user about
|
|
// such situations, we say "host <name> does not provide a <service> service",
|
|
// regardless of whether that is due to that service specifically being absent
|
|
// or due to the host not providing Terraform services at all, since we don't
|
|
// wish to expose the detail of whole-host discovery to an end-user.
|
|
func (d *Disco) Discover(hostname svchost.Hostname) (*Host, error) {
|
|
if host, cached := d.hostCache[hostname]; cached {
|
|
return host, nil
|
|
}
|
|
|
|
host, err := d.discover(hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.hostCache[hostname] = host
|
|
|
|
return host, nil
|
|
}
|
|
|
|
// DiscoverServiceURL is a convenience wrapper for discovery on a given
|
|
// hostname and then looking up a particular service in the result.
|
|
func (d *Disco) DiscoverServiceURL(hostname svchost.Hostname, serviceID string) (*url.URL, error) {
|
|
host, err := d.Discover(hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return host.ServiceURL(serviceID)
|
|
}
|
|
|
|
// discover implements the actual discovery process, with its result cached
|
|
// by the public-facing Discover method.
|
|
func (d *Disco) discover(hostname svchost.Hostname) (*Host, error) {
|
|
discoURL := &url.URL{
|
|
Scheme: "https",
|
|
Host: hostname.String(),
|
|
Path: discoPath,
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: d.Transport,
|
|
Timeout: discoTimeout,
|
|
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
|
|
if len(via) > maxRedirects {
|
|
return errors.New("too many redirects") // this error will never actually be seen
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
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 {
|
|
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname, err)
|
|
}
|
|
if creds != nil {
|
|
// Update the request to include credentials.
|
|
creds.PrepareRequest(req)
|
|
}
|
|
|
|
log.Printf("[DEBUG] Service discovery for %s at %s", hostname, discoURL)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to request discovery document: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
host := &Host{
|
|
// Use the discovery URL from resp.Request in
|
|
// case the client followed any redirects.
|
|
discoURL: resp.Request.URL,
|
|
hostname: hostname.ForDisplay(),
|
|
transport: d.Transport,
|
|
}
|
|
|
|
// Return the host without any services.
|
|
if resp.StatusCode == 404 {
|
|
return host, nil
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("Failed to request discovery document: %s", resp.Status)
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Discovery URL has a malformed Content-Type %q", contentType)
|
|
}
|
|
if mediaType != "application/json" {
|
|
return nil, fmt.Errorf("Discovery URL returned an unsupported Content-Type %q", mediaType)
|
|
}
|
|
|
|
// This doesn't catch chunked encoding, because ContentLength is -1 in that case.
|
|
if resp.ContentLength > maxDiscoDocBytes {
|
|
// Size limit here is not a contractual requirement and so we may
|
|
// adjust it over time if we find a different limit is warranted.
|
|
return nil, fmt.Errorf(
|
|
"Discovery doc response is too large (got %d bytes; limit %d)",
|
|
resp.ContentLength, maxDiscoDocBytes,
|
|
)
|
|
}
|
|
|
|
// If the response is using chunked encoding then we can't predict its
|
|
// size, but we'll at least prevent reading the entire thing into memory.
|
|
lr := io.LimitReader(resp.Body, maxDiscoDocBytes)
|
|
|
|
servicesBytes, err := ioutil.ReadAll(lr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Error reading discovery document body: %v", err)
|
|
}
|
|
|
|
var services map[string]interface{}
|
|
err = json.Unmarshal(servicesBytes, &services)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to decode discovery document as a JSON object: %v", err)
|
|
}
|
|
host.services = services
|
|
|
|
return host, nil
|
|
}
|
|
|
|
// Forget invalidates any cached record of the given hostname. If the host
|
|
// has no cache entry then this is a no-op.
|
|
func (d *Disco) Forget(hostname svchost.Hostname) {
|
|
delete(d.hostCache, hostname)
|
|
}
|
|
|
|
// ForgetAll is like Forget, but for all of the hostnames that have cache entries.
|
|
func (d *Disco) ForgetAll() {
|
|
d.hostCache = make(map[svchost.Hostname]*Host)
|
|
}
|