415 lines
12 KiB
Go
415 lines
12 KiB
Go
package disco
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-version"
|
|
"github.com/hashicorp/terraform/httpclient"
|
|
)
|
|
|
|
const versionServiceID = "versions.v1"
|
|
|
|
// Host represents a service discovered host.
|
|
type Host struct {
|
|
discoURL *url.URL
|
|
hostname string
|
|
services map[string]interface{}
|
|
transport http.RoundTripper
|
|
}
|
|
|
|
// Constraints represents the version constraints of a service.
|
|
type Constraints struct {
|
|
Service string `json:"service"`
|
|
Product string `json:"product"`
|
|
Minimum string `json:"minimum"`
|
|
Maximum string `json:"maximum"`
|
|
Excluding []string `json:"excluding"`
|
|
}
|
|
|
|
// ErrServiceNotProvided is returned when the service is not provided.
|
|
type ErrServiceNotProvided struct {
|
|
hostname string
|
|
service string
|
|
}
|
|
|
|
// Error returns a customized error message.
|
|
func (e *ErrServiceNotProvided) Error() string {
|
|
if e.hostname == "" {
|
|
return fmt.Sprintf("host does not provide a %s service", e.service)
|
|
}
|
|
return fmt.Sprintf("host %s does not provide a %s service", e.hostname, e.service)
|
|
}
|
|
|
|
// ErrVersionNotSupported is returned when the version is not supported.
|
|
type ErrVersionNotSupported struct {
|
|
hostname string
|
|
service string
|
|
version string
|
|
}
|
|
|
|
// Error returns a customized error message.
|
|
func (e *ErrVersionNotSupported) Error() string {
|
|
if e.hostname == "" {
|
|
return fmt.Sprintf("host does not support %s version %s", e.service, e.version)
|
|
}
|
|
return fmt.Sprintf("host %s does not support %s version %s", e.hostname, e.service, e.version)
|
|
}
|
|
|
|
// ErrNoVersionConstraints is returned when checkpoint was disabled
|
|
// or the endpoint to query for version constraints was unavailable.
|
|
type ErrNoVersionConstraints struct {
|
|
disabled bool
|
|
}
|
|
|
|
// Error returns a customized error message.
|
|
func (e *ErrNoVersionConstraints) Error() string {
|
|
if e.disabled {
|
|
return "checkpoint disabled"
|
|
}
|
|
return "unable to contact versions service"
|
|
}
|
|
|
|
// ServiceURL returns the URL associated with the given service identifier,
|
|
// which should be of the form "servicename.vN".
|
|
//
|
|
// A non-nil result is always an absolute URL with a scheme of either HTTPS
|
|
// or HTTP.
|
|
func (h *Host) ServiceURL(id string) (*url.URL, error) {
|
|
svc, ver, err := parseServiceID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// No services supported for an empty Host.
|
|
if h == nil || h.services == nil {
|
|
return nil, &ErrServiceNotProvided{service: svc}
|
|
}
|
|
|
|
urlStr, ok := h.services[id].(string)
|
|
if !ok {
|
|
// See if we have a matching service as that would indicate
|
|
// the service is supported, but not the requested version.
|
|
for serviceID := range h.services {
|
|
if strings.HasPrefix(serviceID, svc+".") {
|
|
return nil, &ErrVersionNotSupported{
|
|
hostname: h.hostname,
|
|
service: svc,
|
|
version: ver.Original(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// No discovered services match the requested service.
|
|
return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc}
|
|
}
|
|
|
|
u, err := h.parseURL(urlStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to parse service URL: %v", err)
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// ServiceOAuthClient returns the OAuth client configuration associated with the
|
|
// given service identifier, which should be of the form "servicename.vN".
|
|
//
|
|
// This is an alternative to ServiceURL for unusual services that require
|
|
// a full OAuth2 client definition rather than just a URL. Use this only
|
|
// for services whose specification calls for this sort of definition.
|
|
func (h *Host) ServiceOAuthClient(id string) (*OAuthClient, error) {
|
|
svc, ver, err := parseServiceID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// No services supported for an empty Host.
|
|
if h == nil || h.services == nil {
|
|
return nil, &ErrServiceNotProvided{service: svc}
|
|
}
|
|
|
|
if _, ok := h.services[id]; !ok {
|
|
// See if we have a matching service as that would indicate
|
|
// the service is supported, but not the requested version.
|
|
for serviceID := range h.services {
|
|
if strings.HasPrefix(serviceID, svc+".") {
|
|
return nil, &ErrVersionNotSupported{
|
|
hostname: h.hostname,
|
|
service: svc,
|
|
version: ver.Original(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// No discovered services match the requested service.
|
|
return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc}
|
|
}
|
|
|
|
var raw map[string]interface{}
|
|
switch v := h.services[id].(type) {
|
|
case map[string]interface{}:
|
|
raw = v // Great!
|
|
case []map[string]interface{}:
|
|
// An absolutely infuriating legacy HCL ambiguity.
|
|
raw = v[0]
|
|
default:
|
|
// Debug message because raw Go types don't belong in our UI.
|
|
log.Printf("[DEBUG] The definition for %s has Go type %T", id, h.services[id])
|
|
return nil, fmt.Errorf("Service %s must be declared with an object value in the service discovery document", id)
|
|
}
|
|
|
|
var grantTypes OAuthGrantTypeSet
|
|
if rawGTs, ok := raw["grant_types"]; ok {
|
|
if gts, ok := rawGTs.([]interface{}); ok {
|
|
var kws []string
|
|
for _, gtI := range gts {
|
|
gt, ok := gtI.(string)
|
|
if !ok {
|
|
// We'll ignore this so that we can potentially introduce
|
|
// other types into this array later if we need to.
|
|
continue
|
|
}
|
|
kws = append(kws, gt)
|
|
}
|
|
grantTypes = NewOAuthGrantTypeSet(kws...)
|
|
} else {
|
|
return nil, fmt.Errorf("Service %s is defined with invalid grant_types property: must be an array of grant type strings", id)
|
|
}
|
|
} else {
|
|
grantTypes = NewOAuthGrantTypeSet("authz_code")
|
|
}
|
|
|
|
ret := &OAuthClient{
|
|
SupportedGrantTypes: grantTypes,
|
|
}
|
|
if clientIDStr, ok := raw["client"].(string); ok {
|
|
ret.ID = clientIDStr
|
|
} else {
|
|
return nil, fmt.Errorf("Service %s definition is missing required property \"client\"", id)
|
|
}
|
|
if urlStr, ok := raw["authz"].(string); ok {
|
|
u, err := h.parseURL(urlStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to parse authorization URL: %v", err)
|
|
}
|
|
ret.AuthorizationURL = u
|
|
} else {
|
|
if grantTypes.RequiresAuthorizationEndpoint() {
|
|
return nil, fmt.Errorf("Service %s definition is missing required property \"authz\"", id)
|
|
}
|
|
}
|
|
if urlStr, ok := raw["token"].(string); ok {
|
|
u, err := h.parseURL(urlStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to parse token URL: %v", err)
|
|
}
|
|
ret.TokenURL = u
|
|
} else {
|
|
if grantTypes.RequiresTokenEndpoint() {
|
|
return nil, fmt.Errorf("Service %s definition is missing required property \"token\"", id)
|
|
}
|
|
}
|
|
if portsRaw, ok := raw["ports"].([]interface{}); ok {
|
|
if len(portsRaw) != 2 {
|
|
return nil, fmt.Errorf("Invalid \"ports\" definition for service %s: must be a two-element array", id)
|
|
}
|
|
invalidPortsErr := fmt.Errorf("Invalid \"ports\" definition for service %s: both ports must be whole numbers between 1024 and 65535", id)
|
|
ports := make([]uint16, 2)
|
|
for i := range ports {
|
|
switch v := portsRaw[i].(type) {
|
|
case float64:
|
|
// JSON unmarshaling always produces float64. HCL 2 might, if
|
|
// an invalid fractional number were given.
|
|
if float64(uint16(v)) != v || v < 1024 {
|
|
return nil, invalidPortsErr
|
|
}
|
|
ports[i] = uint16(v)
|
|
case int:
|
|
// Legacy HCL produces int. HCL 2 will too, if the given number
|
|
// is a whole number.
|
|
if v < 1024 || v > 65535 {
|
|
return nil, invalidPortsErr
|
|
}
|
|
ports[i] = uint16(v)
|
|
default:
|
|
// Debug message because raw Go types don't belong in our UI.
|
|
log.Printf("[DEBUG] Port value %d has Go type %T", i, portsRaw[i])
|
|
return nil, invalidPortsErr
|
|
}
|
|
}
|
|
if ports[1] < ports[0] {
|
|
return nil, fmt.Errorf("Invalid \"ports\" definition for service %s: minimum port cannot be greater than maximum port", id)
|
|
}
|
|
ret.MinPort = ports[0]
|
|
ret.MaxPort = ports[1]
|
|
} else {
|
|
// Default is to accept any port in the range, for a client that is
|
|
// able to call back to any localhost port.
|
|
ret.MinPort = 1024
|
|
ret.MaxPort = 65535
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (h *Host) parseURL(urlStr string) (*url.URL, error) {
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Make relative URLs absolute using our discovery URL.
|
|
if !u.IsAbs() {
|
|
u = h.discoURL.ResolveReference(u)
|
|
}
|
|
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return nil, fmt.Errorf("unsupported scheme %s", u.Scheme)
|
|
}
|
|
if u.User != nil {
|
|
return nil, fmt.Errorf("embedded username/password information is not permitted")
|
|
}
|
|
|
|
// Fragment part is irrelevant, since we're not a browser.
|
|
u.Fragment = ""
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// VersionConstraints returns the contraints for a given service identifier
|
|
// (which should be of the form "servicename.vN") and product.
|
|
//
|
|
// When an exact (service and version) match is found, the constraints for
|
|
// that service are returned.
|
|
//
|
|
// When the requested version is not provided but the service is, we will
|
|
// search for all alternative versions. If mutliple alternative versions
|
|
// are found, the contrains of the latest available version are returned.
|
|
//
|
|
// When a service is not provided at all an error will be returned instead.
|
|
//
|
|
// When checkpoint is disabled or when a 404 is returned after making the
|
|
// HTTP call, an ErrNoVersionConstraints error will be returned.
|
|
func (h *Host) VersionConstraints(id, product string) (*Constraints, error) {
|
|
svc, _, err := parseServiceID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Return early if checkpoint is disabled.
|
|
if disabled := os.Getenv("CHECKPOINT_DISABLE"); disabled != "" {
|
|
return nil, &ErrNoVersionConstraints{disabled: true}
|
|
}
|
|
|
|
// No services supported for an empty Host.
|
|
if h == nil || h.services == nil {
|
|
return nil, &ErrServiceNotProvided{service: svc}
|
|
}
|
|
|
|
// Try to get the service URL for the version service and
|
|
// return early if the service isn't provided by the host.
|
|
u, err := h.ServiceURL(versionServiceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if we have an exact (service and version) match.
|
|
if _, ok := h.services[id].(string); !ok {
|
|
// If we don't have an exact match, we search for all matching
|
|
// services and then use the service ID of the latest version.
|
|
var services []string
|
|
for serviceID := range h.services {
|
|
if strings.HasPrefix(serviceID, svc+".") {
|
|
services = append(services, serviceID)
|
|
}
|
|
}
|
|
|
|
if len(services) == 0 {
|
|
// No discovered services match the requested service.
|
|
return nil, &ErrServiceNotProvided{hostname: h.hostname, service: svc}
|
|
}
|
|
|
|
// Set id to the latest service ID we found.
|
|
var latest *version.Version
|
|
for _, serviceID := range services {
|
|
if _, ver, err := parseServiceID(serviceID); err == nil {
|
|
if latest == nil || latest.LessThan(ver) {
|
|
id = serviceID
|
|
latest = ver
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set a default timeout of 1 sec for the versions request (in milliseconds)
|
|
timeout := 1000
|
|
if v, err := strconv.Atoi(os.Getenv("CHECKPOINT_TIMEOUT")); err == nil {
|
|
timeout = v
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: h.transport,
|
|
Timeout: time.Duration(timeout) * time.Millisecond,
|
|
}
|
|
|
|
// Prepare the service URL by setting the service and product.
|
|
v := u.Query()
|
|
v.Set("product", product)
|
|
u.Path += id
|
|
u.RawQuery = v.Encode()
|
|
|
|
// Create a new request.
|
|
req, err := http.NewRequest("GET", u.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to create version constraints request: %v", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", httpclient.UserAgentString())
|
|
|
|
log.Printf("[DEBUG] Retrieve version constraints for service %s and product %s", id, product)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to request version constraints: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 404 {
|
|
return nil, &ErrNoVersionConstraints{disabled: false}
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("Failed to request version constraints: %s", resp.Status)
|
|
}
|
|
|
|
// Parse the constraints from the response body.
|
|
result := &Constraints{}
|
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
|
return nil, fmt.Errorf("Error parsing version constraints: %v", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseServiceID(id string) (string, *version.Version, error) {
|
|
parts := strings.SplitN(id, ".", 2)
|
|
if len(parts) != 2 {
|
|
return "", nil, fmt.Errorf("Invalid service ID format (i.e. service.vN): %s", id)
|
|
}
|
|
|
|
version, err := version.NewVersion(parts[1])
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("Invalid service version: %v", err)
|
|
}
|
|
|
|
return parts[0], version, nil
|
|
}
|