]>
Commit | Line | Data |
---|---|---|
15c0b25d AP |
1 | package disco |
2 | ||
3 | import ( | |
4 | "encoding/json" | |
5 | "fmt" | |
6 | "log" | |
7 | "net/http" | |
8 | "net/url" | |
9 | "os" | |
10 | "strconv" | |
11 | "strings" | |
12 | "time" | |
13 | ||
14 | "github.com/hashicorp/go-version" | |
15 | "github.com/hashicorp/terraform/httpclient" | |
16 | ) | |
17 | ||
18 | const versionServiceID = "versions.v1" | |
19 | ||
20 | // Host represents a service discovered host. | |
21 | type Host struct { | |
22 | discoURL *url.URL | |
23 | hostname string | |
24 | services map[string]interface{} | |
25 | transport http.RoundTripper | |
26 | } | |
27 | ||
28 | // Constraints represents the version constraints of a service. | |
29 | type Constraints struct { | |
30 | Service string `json:"service"` | |
31 | Product string `json:"product"` | |
32 | Minimum string `json:"minimum"` | |
33 | Maximum string `json:"maximum"` | |
34 | Excluding []string `json:"excluding"` | |
35 | } | |
36 | ||
37 | // ErrServiceNotProvided is returned when the service is not provided. | |
38 | type ErrServiceNotProvided struct { | |
39 | hostname string | |
40 | service string | |
41 | } | |
42 | ||
43 | // Error returns a customized error message. | |
44 | func (e *ErrServiceNotProvided) Error() string { | |
45 | if e.hostname == "" { | |
46 | return fmt.Sprintf("host does not provide a %s service", e.service) | |
47 | } | |
48 | return fmt.Sprintf("host %s does not provide a %s service", e.hostname, e.service) | |
49 | } | |
50 | ||
51 | // ErrVersionNotSupported is returned when the version is not supported. | |
52 | type ErrVersionNotSupported struct { | |
53 | hostname string | |
54 | service string | |
55 | version string | |
56 | } | |
57 | ||
58 | // Error returns a customized error message. | |
59 | func (e *ErrVersionNotSupported) Error() string { | |
60 | if e.hostname == "" { | |
61 | return fmt.Sprintf("host does not support %s version %s", e.service, e.version) | |
62 | } | |
63 | return fmt.Sprintf("host %s does not support %s version %s", e.hostname, e.service, e.version) | |
64 | } | |
65 | ||
66 | // ErrNoVersionConstraints is returned when checkpoint was disabled | |
67 | // or the endpoint to query for version constraints was unavailable. | |
68 | type ErrNoVersionConstraints struct { | |
69 | disabled bool | |
70 | } | |
71 | ||
72 | // Error returns a customized error message. | |
73 | func (e *ErrNoVersionConstraints) Error() string { | |
74 | if e.disabled { | |
75 | return "checkpoint disabled" | |
76 | } | |
77 | return "unable to contact versions service" | |
78 | } | |
79 | ||
80 | // ServiceURL returns the URL associated with the given service identifier, | |
81 | // which should be of the form "servicename.vN". | |
82 | // | |
83 | // A non-nil result is always an absolute URL with a scheme of either HTTPS | |
84 | // or HTTP. | |
85 | func (h *Host) ServiceURL(id string) (*url.URL, error) { | |
86 | svc, ver, err := parseServiceID(id) | |
87 | if err != nil { | |
88 | return nil, err | |
89 | } | |
90 | ||
91 | // No services supported for an empty Host. | |
92 | if h == nil || h.services == nil { | |
93 | return nil, &ErrServiceNotProvided{service: svc} | |
94 | } | |
95 | ||
96 | urlStr, ok := h.services[id].(string) | |
97 | if !ok { | |
98 | // See if we have a matching service as that would indicate | |
99 | // the service is supported, but not the requested version. | |
100 | for serviceID := range h.services { | |
101 | if strings.HasPrefix(serviceID, svc+".") { | |
102 | return nil, &ErrVersionNotSupported{ | |
103 | hostname: h.hostname, | |
104 | service: svc, | |
105 | version: ver.Original(), | |
106 | } | |
107 | } | |
108 | } | |
109 | ||
110 | // No discovered services match the requested service. | |
111 | return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} | |
112 | } | |
113 | ||
114 | u, err := url.Parse(urlStr) | |
115 | if err != nil { | |
116 | return nil, fmt.Errorf("Failed to parse service URL: %v", err) | |
117 | } | |
118 | ||
119 | // Make relative URLs absolute using our discovery URL. | |
120 | if !u.IsAbs() { | |
121 | u = h.discoURL.ResolveReference(u) | |
122 | } | |
123 | ||
124 | if u.Scheme != "https" && u.Scheme != "http" { | |
125 | return nil, fmt.Errorf("Service URL is using an unsupported scheme: %s", u.Scheme) | |
126 | } | |
127 | if u.User != nil { | |
128 | return nil, fmt.Errorf("Embedded username/password information is not permitted") | |
129 | } | |
130 | ||
131 | // Fragment part is irrelevant, since we're not a browser. | |
132 | u.Fragment = "" | |
133 | ||
134 | return h.discoURL.ResolveReference(u), nil | |
135 | } | |
136 | ||
137 | // VersionConstraints returns the contraints for a given service identifier | |
138 | // (which should be of the form "servicename.vN") and product. | |
139 | // | |
140 | // When an exact (service and version) match is found, the constraints for | |
141 | // that service are returned. | |
142 | // | |
143 | // When the requested version is not provided but the service is, we will | |
144 | // search for all alternative versions. If mutliple alternative versions | |
145 | // are found, the contrains of the latest available version are returned. | |
146 | // | |
147 | // When a service is not provided at all an error will be returned instead. | |
148 | // | |
149 | // When checkpoint is disabled or when a 404 is returned after making the | |
150 | // HTTP call, an ErrNoVersionConstraints error will be returned. | |
151 | func (h *Host) VersionConstraints(id, product string) (*Constraints, error) { | |
152 | svc, _, err := parseServiceID(id) | |
153 | if err != nil { | |
154 | return nil, err | |
155 | } | |
156 | ||
157 | // Return early if checkpoint is disabled. | |
158 | if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" { | |
159 | return nil, &ErrNoVersionConstraints{disabled: true} | |
160 | } | |
161 | ||
162 | // No services supported for an empty Host. | |
163 | if h == nil || h.services == nil { | |
164 | return nil, &ErrServiceNotProvided{service: svc} | |
165 | } | |
166 | ||
167 | // Try to get the service URL for the version service and | |
168 | // return early if the service isn't provided by the host. | |
169 | u, err := h.ServiceURL(versionServiceID) | |
170 | if err != nil { | |
171 | return nil, err | |
172 | } | |
173 | ||
174 | // Check if we have an exact (service and version) match. | |
175 | if _, ok := h.services[id].(string); !ok { | |
176 | // If we don't have an exact match, we search for all matching | |
177 | // services and then use the service ID of the latest version. | |
178 | var services []string | |
179 | for serviceID := range h.services { | |
180 | if strings.HasPrefix(serviceID, svc+".") { | |
181 | services = append(services, serviceID) | |
182 | } | |
183 | } | |
184 | ||
185 | if len(services) == 0 { | |
186 | // No discovered services match the requested service. | |
187 | return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc} | |
188 | } | |
189 | ||
190 | // Set id to the latest service ID we found. | |
191 | var latest *version.Version | |
192 | for _, serviceID := range services { | |
193 | if _, ver, err := parseServiceID(serviceID); err == nil { | |
194 | if latest == nil || latest.LessThan(ver) { | |
195 | id = serviceID | |
196 | latest = ver | |
197 | } | |
198 | } | |
199 | } | |
200 | } | |
201 | ||
202 | // Set a default timeout of 1 sec for the versions request (in milliseconds) | |
203 | timeout := 1000 | |
204 | if v, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil { | |
205 | timeout = v | |
206 | } | |
207 | ||
208 | client := &http.Client{ | |
209 | Transport: h.transport, | |
210 | Timeout: time.Duration(timeout) * time.Millisecond, | |
211 | } | |
212 | ||
213 | // Prepare the service URL by setting the service and product. | |
214 | v := u.Query() | |
215 | v.Set("product", product) | |
216 | u.Path += id | |
217 | u.RawQuery = v.Encode() | |
218 | ||
219 | // Create a new request. | |
220 | req, err := http.NewRequest("GET", u.String(), nil) | |
221 | if err != nil { | |
222 | return nil, fmt.Errorf("Failed to create version constraints request: %v", err) | |
223 | } | |
224 | req.Header.Set("Accept", "application/json") | |
225 | req.Header.Set("User-Agent", httpclient.UserAgentString()) | |
226 | ||
227 | log.Printf("[DEBUG] Retrieve version constraints for service %s and product %s", id, product) | |
228 | ||
229 | resp, err := client.Do(req) | |
230 | if err != nil { | |
231 | return nil, fmt.Errorf("Failed to request version constraints: %v", err) | |
232 | } | |
233 | defer resp.Body.Close() | |
234 | ||
235 | if resp.StatusCode == 404 { | |
236 | return nil, &ErrNoVersionConstraints{disabled: false} | |
237 | } | |
238 | ||
239 | if resp.StatusCode != 200 { | |
240 | return nil, fmt.Errorf("Failed to request version constraints: %s", resp.Status) | |
241 | } | |
242 | ||
243 | // Parse the constraints from the response body. | |
244 | result := &Constraints{} | |
245 | if err := json.NewDecoder(resp.Body).Decode(result); err != nil { | |
246 | return nil, fmt.Errorf("Error parsing version constraints: %v", err) | |
247 | } | |
248 | ||
249 | return result, nil | |
250 | } | |
251 | ||
252 | func parseServiceID(id string) (string, *version.Version, error) { | |
253 | parts := strings.SplitN(id, ".", 2) | |
254 | if len(parts) != 2 { | |
255 | return "", nil, fmt.Errorf("Invalid service ID format (i.e. service.vN): %s", id) | |
256 | } | |
257 | ||
258 | version, err := version.NewVersion(parts[1]) | |
259 | if err != nil { | |
260 | return "", nil, fmt.Errorf("Invalid service version: %v", err) | |
261 | } | |
262 | ||
263 | return parts[0], version, nil | |
264 | } |