diff --git a/client/fingerprint/env_digitalocean.go b/client/fingerprint/env_digitalocean.go new file mode 100644 index 000000000..3cbf87523 --- /dev/null +++ b/client/fingerprint/env_digitalocean.go @@ -0,0 +1,180 @@ +package fingerprint + +import ( + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + "time" + + cleanhttp "github.com/hashicorp/go-cleanhttp" + log "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/nomad/helper/useragent" + "github.com/hashicorp/nomad/nomad/structs" +) + +const ( + // DigitalOceanMetadataURL is where the DigitalOcean metadata server normally resides. We hardcode the + // "instance" path as well since it's the only one we access here. + DigitalOceanMetadataURL = "http://169.254.169.254/metadata/v1/" + + // DigitalOceanMetadataTimeout is the timeout used when contacting the DigitalOcean metadata + // services. + DigitalOceanMetadataTimeout = 2 * time.Second +) + +type DigitalOceanMetadataTag struct { + Name string + Value string +} + +type DigitalOceanMetadataPair struct { + path string + unique bool +} + +// EnvDigitalOceanFingerprint is used to fingerprint DigitalOcean metadata +type EnvDigitalOceanFingerprint struct { + StaticFingerprinter + client *http.Client + logger log.Logger + metadataURL string +} + +// NewEnvDigitalOceanFingerprint is used to create a fingerprint from DigitalOcean metadata +func NewEnvDigitalOceanFingerprint(logger log.Logger) Fingerprint { + // Read the internal metadata URL from the environment, allowing test files to + // provide their own + metadataURL := os.Getenv("DO_ENV_URL") + if metadataURL == "" { + metadataURL = DigitalOceanMetadataURL + } + + // assume 2 seconds is enough time for inside DigitalOcean network + client := &http.Client{ + Timeout: DigitalOceanMetadataTimeout, + Transport: cleanhttp.DefaultTransport(), + } + + return &EnvDigitalOceanFingerprint{ + client: client, + logger: logger.Named("env_digitalocean"), + metadataURL: metadataURL, + } +} + +func (f *EnvDigitalOceanFingerprint) Get(attribute string, format string) (string, error) { + reqURL := f.metadataURL + attribute + parsedURL, err := url.Parse(reqURL) + if err != nil { + return "", err + } + + req := &http.Request{ + Method: "GET", + URL: parsedURL, + Header: http.Header{ + "Metadata": []string{"true"}, + "User-Agent": []string{useragent.String()}, + }, + } + + res, err := f.client.Do(req) + if err != nil { + f.logger.Debug("could not read value for attribute", "attribute", attribute, "error", err) + return "", err + } else if res.StatusCode != http.StatusOK { + f.logger.Debug("could not read value for attribute", "attribute", attribute, "resp_code", res.StatusCode) + return "", err + } + + resp, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + f.logger.Error("error reading response body for DigitalOcean attribute", "attribute", attribute, "error", err) + return "", err + } + + if res.StatusCode >= 400 { + return "", ReqError{res.StatusCode} + } + + return string(resp), nil +} + +func checkDigitalOceanError(err error, logger log.Logger, desc string) error { + // If it's a URL error, assume we're not actually in an DigitalOcean environment. + // To the outer layers, this isn't an error so return nil. + if _, ok := err.(*url.Error); ok { + logger.Debug("error querying DigitalOcean attribute; skipping", "attribute", desc) + return nil + } + // Otherwise pass the error through. + return err +} + +func (f *EnvDigitalOceanFingerprint) Fingerprint(request *FingerprintRequest, response *FingerprintResponse) error { + cfg := request.Config + + // Check if we should tighten the timeout + if cfg.ReadBoolDefault(TightenNetworkTimeoutsConfig, false) { + f.client.Timeout = 1 * time.Millisecond + } + + if !f.isDigitalOcean() { + return nil + } + + // Keys and whether they should be namespaced as unique. Any key whose value + // uniquely identifies a node, such as ip, should be marked as unique. When + // marked as unique, the key isn't included in the computed node class. + keys := map[string]DigitalOceanMetadataPair{ + "id": {unique: true, path: "id"}, + "hostname": {unique: true, path: "hostname"}, + "region": {unique: false, path: "region"}, + "private-ipv4": {unique: true, path: "interfaces/private/0/ipv4/address"}, + "public-ipv4": {unique: true, path: "interfaces/public/0/ipv4/address"}, + "private-ipv6": {unique: true, path: "interfaces/private/0/ipv6/address"}, + "public-ipv6": {unique: true, path: "interfaces/public/0/ipv6/address"}, + "mac": {unique: true, path: "interfaces/public/0/mac"}, + } + + for k, attr := range keys { + resp, err := f.Get(attr.path, "text") + v := strings.TrimSpace(resp) + if err != nil { + return checkDigitalOceanError(err, f.logger, k) + } else if v == "" { + f.logger.Debug("read an empty value", "attribute", k) + continue + } + + // assume we want blank entries + key := "platform.digitalocean." + strings.ReplaceAll(k, "/", ".") + if attr.unique { + key = structs.UniqueNamespace(key) + } + response.AddAttribute(key, v) + } + + // copy over network specific information + if val, ok := response.Attributes["unique.platform.digitalocean.local-ipv4"]; ok && val != "" { + response.AddAttribute("unique.network.ip-address", val) + } + + // populate Links + if id, ok := response.Attributes["unique.platform.digitalocean.id"]; ok { + response.AddLink("digitalocean", id) + } + + response.Detected = true + return nil +} + +func (f *EnvDigitalOceanFingerprint) isDigitalOcean() bool { + v, err := f.Get("region", "text") + v = strings.TrimSpace(v) + return err == nil && v != "" +} diff --git a/client/fingerprint/env_digitalocean_test.go b/client/fingerprint/env_digitalocean_test.go new file mode 100644 index 000000000..de0ca2047 --- /dev/null +++ b/client/fingerprint/env_digitalocean_test.go @@ -0,0 +1,174 @@ +package fingerprint + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/hashicorp/nomad/client/config" + "github.com/hashicorp/nomad/helper/testlog" + "github.com/hashicorp/nomad/nomad/structs" +) + +func TestDigitalOceanFingerprint_nonDigitalOcean(t *testing.T) { + os.Setenv("DO_ENV_URL", "http://127.0.0.1/metadata/v1/") + f := NewEnvDigitalOceanFingerprint(testlog.HCLogger(t)) + node := &structs.Node{ + Attributes: make(map[string]string), + } + + request := &FingerprintRequest{Config: &config.Config{}, Node: node} + var response FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } + + if response.Detected { + t.Fatalf("expected response to not be applicable") + } + + if len(response.Attributes) > 0 { + t.Fatalf("Should have zero attributes without test server") + } +} + +func TestFingerprint_DigitalOcean(t *testing.T) { + node := &structs.Node{ + Attributes: make(map[string]string), + } + + // configure mock server with fixture routes, data + routes := routes{} + if err := json.Unmarshal([]byte(DO_routes), &routes); err != nil { + t.Fatalf("Failed to unmarshal JSON in GCE ENV test: %s", err) + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + value, ok := r.Header["Metadata"] + if !ok { + t.Fatal("Metadata not present in HTTP request header") + } + if value[0] != "true" { + t.Fatalf("Expected Metadata true, saw %s", value[0]) + } + + uavalue, ok := r.Header["User-Agent"] + if !ok { + t.Fatal("User-Agent not present in HTTP request header") + } + if !strings.Contains(uavalue[0], "Nomad/") { + t.Fatalf("Expected User-Agent to contain Nomad/, got %s", uavalue[0]) + } + + uri := r.RequestURI + if r.URL.RawQuery != "" { + uri = strings.Replace(uri, "?"+r.URL.RawQuery, "", 1) + } + + found := false + for _, e := range routes.Endpoints { + if uri == e.Uri { + w.Header().Set("Content-Type", e.ContentType) + fmt.Fprintln(w, e.Body) + found = true + } + } + + if !found { + w.WriteHeader(404) + } + })) + defer ts.Close() + os.Setenv("DO_ENV_URL", ts.URL+"/metadata/v1/") + f := NewEnvDigitalOceanFingerprint(testlog.HCLogger(t)) + + request := &FingerprintRequest{Config: &config.Config{}, Node: node} + var response FingerprintResponse + err := f.Fingerprint(request, &response) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !response.Detected { + t.Fatalf("expected response to be applicable") + } + + // TODO: tags + keys := []string{ + "unique.platform.digitalocean.id", + "unique.platform.digitalocean.hostname", + "platform.digitalocean.region", + "unique.platform.digitalocean.private-ipv4", + "unique.platform.digitalocean.public-ipv4", + "unique.platform.digitalocean.public-ipv6", + "unique.platform.digitalocean.mac", + } + + for _, k := range keys { + assertNodeAttributeContains(t, response.Attributes, k) + } + + if len(response.Links) == 0 { + t.Fatalf("Empty links for Node in DO Fingerprint test") + } + + // Make sure Links contains the GCE ID. + for _, k := range []string{"digitalocean"} { + assertNodeLinksContains(t, response.Links, k) + } + + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.id", "13f56399-bd52-4150-9748-7190aae1ff21") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.hostname", "demo01.internal") + assertNodeAttributeEquals(t, response.Attributes, "platform.digitalocean.region", "sfo3") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.private-ipv4", "10.1.0.4") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.mac", "000D3AF806EC") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.public-ipv4", "100.100.100.100") + assertNodeAttributeEquals(t, response.Attributes, "unique.platform.digitalocean.public-ipv6", "c99c:8ac5:3112:204b:48b0:41aa:e085:d11a") +} + +const DO_routes = ` +{ + "endpoints": [ + { + "uri": "/metadata/v1/region", + "content-type": "text/plain", + "body": "sfo3" + }, + { + "uri": "/metadata/v1/hostname", + "content-type": "text/plain", + "body": "demo01.internal" + }, + { + "uri": "/metadata/v1/id", + "content-type": "text/plain", + "body": "13f56399-bd52-4150-9748-7190aae1ff21" + }, + { + "uri": "/metadata/v1/interfaces/private/0/ipv4/address", + "content-type": "text/plain", + "body": "10.1.0.4" + }, + { + "uri": "/metadata/v1/interfaces/public/0/mac", + "content-type": "text/plain", + "body": "000D3AF806EC" + }, + { + "uri": "/metadata/v1/interfaces/public/0/ipv4/address", + "content-type": "text/plain", + "body": "100.100.100.100" + }, + { + "uri": "/metadata/v1/interfaces/public/0/ipv6/address", + "content-type": "text/plain", + "body": "c99c:8ac5:3112:204b:48b0:41aa:e085:d11a" + } + ] +} +` diff --git a/client/fingerprint/fingerprint.go b/client/fingerprint/fingerprint.go index 8953de880..a12ea98f4 100644 --- a/client/fingerprint/fingerprint.go +++ b/client/fingerprint/fingerprint.go @@ -46,9 +46,10 @@ var ( // This should run after the host fingerprinters as they may override specific // node resources with more detailed information. envFingerprinters = map[string]Factory{ - "env_aws": NewEnvAWSFingerprint, - "env_gce": NewEnvGCEFingerprint, - "env_azure": NewEnvAzureFingerprint, + "env_aws": NewEnvAWSFingerprint, + "env_gce": NewEnvGCEFingerprint, + "env_azure": NewEnvAzureFingerprint, + "env_digitalocean": NewEnvDigitalOceanFingerprint, } )