From 4d4c8397962f13dbc60e68bf46229ed760e21b85 Mon Sep 17 00:00:00 2001 From: Kevin Schoonover Date: Thu, 3 Feb 2022 21:01:59 -0800 Subject: [PATCH 1/3] add digitalocean fingerprinter --- client/fingerprint/env_digitalocean.go | 180 ++++++++++++++++++++ client/fingerprint/env_digitalocean_test.go | 174 +++++++++++++++++++ client/fingerprint/fingerprint.go | 7 +- 3 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 client/fingerprint/env_digitalocean.go create mode 100644 client/fingerprint/env_digitalocean_test.go diff --git a/client/fingerprint/env_digitalocean.go b/client/fingerprint/env_digitalocean.go new file mode 100644 index 00000000000..3cbf875238c --- /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 00000000000..de0ca2047cd --- /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 8953de880e1..a12ea98f442 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, } ) From 7b6f9540db36f6643234b6032b266aa37031e4a7 Mon Sep 17 00:00:00 2001 From: Kevin Schoonover Date: Sat, 5 Feb 2022 22:23:43 -0800 Subject: [PATCH 2/3] small fixes --- client/fingerprint/env_digitalocean.go | 9 +-------- client/fingerprint/env_digitalocean_test.go | 5 ++--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/client/fingerprint/env_digitalocean.go b/client/fingerprint/env_digitalocean.go index 3cbf875238c..517ab5c290b 100644 --- a/client/fingerprint/env_digitalocean.go +++ b/client/fingerprint/env_digitalocean.go @@ -16,8 +16,7 @@ import ( ) 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 is where the DigitalOcean metadata api normally resides. DigitalOceanMetadataURL = "http://169.254.169.254/metadata/v1/" // DigitalOceanMetadataTimeout is the timeout used when contacting the DigitalOcean metadata @@ -25,11 +24,6 @@ const ( DigitalOceanMetadataTimeout = 2 * time.Second ) -type DigitalOceanMetadataTag struct { - Name string - Value string -} - type DigitalOceanMetadataPair struct { path string unique bool @@ -76,7 +70,6 @@ func (f *EnvDigitalOceanFingerprint) Get(attribute string, format string) (strin Method: "GET", URL: parsedURL, Header: http.Header{ - "Metadata": []string{"true"}, "User-Agent": []string{useragent.String()}, }, } diff --git a/client/fingerprint/env_digitalocean_test.go b/client/fingerprint/env_digitalocean_test.go index de0ca2047cd..35704ed59aa 100644 --- a/client/fingerprint/env_digitalocean_test.go +++ b/client/fingerprint/env_digitalocean_test.go @@ -45,7 +45,7 @@ func TestFingerprint_DigitalOcean(t *testing.T) { // 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) + t.Fatalf("Failed to unmarshal JSON in DO ENV test: %s", err) } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -98,7 +98,6 @@ func TestFingerprint_DigitalOcean(t *testing.T) { t.Fatalf("expected response to be applicable") } - // TODO: tags keys := []string{ "unique.platform.digitalocean.id", "unique.platform.digitalocean.hostname", @@ -117,7 +116,7 @@ func TestFingerprint_DigitalOcean(t *testing.T) { t.Fatalf("Empty links for Node in DO Fingerprint test") } - // Make sure Links contains the GCE ID. + // Make sure Links contains the DO ID. for _, k := range []string{"digitalocean"} { assertNodeLinksContains(t, response.Links, k) } From 5cea36639dd8914fd473246cdbc6919c57a527be Mon Sep 17 00:00:00 2001 From: Kevin Schoonover Date: Mon, 7 Feb 2022 08:48:42 -0800 Subject: [PATCH 3/3] address comments Co-authored-by: Seth Hoenig --- client/fingerprint/env_digitalocean.go | 34 ++++++++------------- client/fingerprint/env_digitalocean_test.go | 14 +++------ 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/client/fingerprint/env_digitalocean.go b/client/fingerprint/env_digitalocean.go index 517ab5c290b..f899ac6a833 100644 --- a/client/fingerprint/env_digitalocean.go +++ b/client/fingerprint/env_digitalocean.go @@ -1,6 +1,7 @@ package fingerprint import ( + "fmt" "io/ioutil" "net/http" "net/url" @@ -17,6 +18,7 @@ import ( const ( // DigitalOceanMetadataURL is where the DigitalOcean metadata api normally resides. + // https://docs.digitalocean.com/products/droplets/how-to/retrieve-droplet-metadata/#how-to-retrieve-droplet-metadata DigitalOceanMetadataURL = "http://169.254.169.254/metadata/v1/" // DigitalOceanMetadataTimeout is the timeout used when contacting the DigitalOcean metadata @@ -67,7 +69,7 @@ func (f *EnvDigitalOceanFingerprint) Get(attribute string, format string) (strin } req := &http.Request{ - Method: "GET", + Method: http.MethodGet, URL: parsedURL, Header: http.Header{ "User-Agent": []string{useragent.String()}, @@ -76,36 +78,23 @@ func (f *EnvDigitalOceanFingerprint) Get(attribute string, format string) (strin 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) + f.logger.Debug("failed to request metadata", "attribute", attribute, "error", err) return "", err } - resp, err := ioutil.ReadAll(res.Body) + body, 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) + f.logger.Error("failed to read metadata", "attribute", attribute, "error", err, "resp_code", res.StatusCode) return "", err } - if res.StatusCode >= 400 { - return "", ReqError{res.StatusCode} + if res.StatusCode != http.StatusOK { + f.logger.Debug("could not read value for attribute", "attribute", attribute, "resp_code", res.StatusCode) + return "", fmt.Errorf("error reading attribute %s. digitalocean metadata api returned an error: resp_code: %d, resp_body: %s", attribute, res.StatusCode, body) } - 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 + return string(body), nil } func (f *EnvDigitalOceanFingerprint) Fingerprint(request *FingerprintRequest, response *FingerprintResponse) error { @@ -138,7 +127,8 @@ func (f *EnvDigitalOceanFingerprint) Fingerprint(request *FingerprintRequest, re resp, err := f.Get(attr.path, "text") v := strings.TrimSpace(resp) if err != nil { - return checkDigitalOceanError(err, f.logger, k) + f.logger.Warn("failed to read attribute", "attribute", k, "err", err) + continue } else if v == "" { f.logger.Debug("read an empty value", "attribute", k) continue diff --git a/client/fingerprint/env_digitalocean_test.go b/client/fingerprint/env_digitalocean_test.go index 35704ed59aa..f5d0b850360 100644 --- a/client/fingerprint/env_digitalocean_test.go +++ b/client/fingerprint/env_digitalocean_test.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/helper/testlog" "github.com/hashicorp/nomad/nomad/structs" + "github.com/stretchr/testify/assert" ) func TestDigitalOceanFingerprint_nonDigitalOcean(t *testing.T) { @@ -90,13 +91,8 @@ func TestFingerprint_DigitalOcean(t *testing.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") - } + assert.NoError(t, err) + assert.True(t, response.Detected, "expected response to be applicable") keys := []string{ "unique.platform.digitalocean.id", @@ -112,9 +108,7 @@ func TestFingerprint_DigitalOcean(t *testing.T) { assertNodeAttributeContains(t, response.Attributes, k) } - if len(response.Links) == 0 { - t.Fatalf("Empty links for Node in DO Fingerprint test") - } + assert.NotEmpty(t, response.Links, "Empty links for Node in DO Fingerprint test") // Make sure Links contains the DO ID. for _, k := range []string{"digitalocean"} {