]>
Commit | Line | Data |
---|---|---|
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 | } |