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 }