1 // Package disco handles Terraform's remote service discovery protocol.
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.
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"
27 // Fixed path to the discovery manifest.
28 discoPath = "/.well-known/terraform.json"
30 // Arbitrary-but-small number to prevent runaway redirect loops.
33 // Arbitrary-but-small time limit to prevent UI "hangs" during discovery.
34 discoTimeout = 11 * time.Second
36 // 1MB - to prevent abusive services from using loads of our memory.
37 maxDiscoDocBytes = 1 * 1024 * 1024
40 // httpTransport is overridden during tests, to skip TLS verification.
41 var httpTransport = cleanhttp.DefaultPooledTransport()
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.
47 hostCache map[svchost.Hostname]*Host
48 credsSrc auth.CredentialsSource
50 // Transport is a custom http.RoundTripper to use.
51 Transport http.RoundTripper
54 // New returns a new initialized discovery object.
56 return NewWithCredentialsSource(nil)
59 // NewWithCredentialsSource returns a new discovery object initialized with
60 // the given credentials source.
61 func NewWithCredentialsSource(credsSrc auth.CredentialsSource) *Disco {
63 hostCache: make(map[svchost.Hostname]*Host),
65 Transport: httpTransport,
69 // SetCredentialsSource provides a credentials source that will be used to
70 // add credentials to outgoing discovery requests, where available.
72 // If this method is never called, no outgoing discovery requests will have
74 func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
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 {
84 return d.credsSrc.ForHost(hostname)
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
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{}) {
99 services = map[string]interface{}{}
102 d.hostCache[hostname] = &Host{
105 Host: string(hostname),
108 hostname: hostname.ForDisplay(),
110 transport: d.Transport,
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.
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 {
129 host, err := d.discover(hostname)
133 d.hostCache[hostname] = host
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)
145 return host.ServiceURL(serviceID)
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{
153 Host: hostname.String(),
157 client := &http.Client{
158 Transport: d.Transport,
159 Timeout: discoTimeout,
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
170 req := &http.Request{
171 Header: make(http.Header),
175 req.Header.Set("Accept", "application/json")
176 req.Header.Set("User-Agent", httpclient.UserAgentString())
178 creds, err := d.CredentialsForHost(hostname)
180 log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname, err)
183 // Update the request to include credentials.
184 creds.PrepareRequest(req)
187 log.Printf("[DEBUG] Service discovery for %s at %s", hostname, discoURL)
189 resp, err := client.Do(req)
191 return nil, fmt.Errorf("Failed to request discovery document: %v", err)
193 defer resp.Body.Close()
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,
203 // Return the host without any services.
204 if resp.StatusCode == 404 {
208 if resp.StatusCode != 200 {
209 return nil, fmt.Errorf("Failed to request discovery document: %s", resp.Status)
212 contentType := resp.Header.Get("Content-Type")
213 mediaType, _, err := mime.ParseMediaType(contentType)
215 return nil, fmt.Errorf("Discovery URL has a malformed Content-Type %q", contentType)
217 if mediaType != "application/json" {
218 return nil, fmt.Errorf("Discovery URL returned an unsupported Content-Type %q", mediaType)
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,
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)
235 servicesBytes, err := ioutil.ReadAll(lr)
237 return nil, fmt.Errorf("Error reading discovery document body: %v", err)
240 var services map[string]interface{}
241 err = json.Unmarshal(servicesBytes, &services)
243 return nil, fmt.Errorf("Failed to decode discovery document as a JSON object: %v", err)
245 host.services = services
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)
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)