1package disco
2
3import (
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
18const versionServiceID = "versions.v1"
19
20// Host represents a service discovered host.
21type 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.
29type 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.
38type ErrServiceNotProvided struct {
39	hostname string
40	service  string
41}
42
43// Error returns a customized error message.
44func (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.
52type ErrVersionNotSupported struct {
53	hostname string
54	service  string
55	version  string
56}
57
58// Error returns a customized error message.
59func (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.
68type ErrNoVersionConstraints struct {
69	disabled bool
70}
71
72// Error returns a customized error message.
73func (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.
85func (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.
151func (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
252func 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}
265