Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

client/fingerprint: add digitalocean fingerprinter #12015

Merged
merged 3 commits into from
Feb 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions client/fingerprint/env_digitalocean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package fingerprint

import (
"fmt"
"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 api normally resides.
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
// 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
// services.
DigitalOceanMetadataTimeout = 2 * time.Second
)

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: http.MethodGet,
URL: parsedURL,
Header: http.Header{
"User-Agent": []string{useragent.String()},
},
}

res, err := f.client.Do(req)
if err != nil {
f.logger.Debug("failed to request metadata", "attribute", attribute, "error", err)
return "", err
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error could be nil; its value is unrelated to the status code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this branch lower in the function to include the resp body in the error message. Not 100% sure if this is safe (i.e. will digitalocean always return some body with an error response), but it would improve debugability if it does work

}

body, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
f.logger.Error("failed to read metadata", "attribute", attribute, "error", err, "resp_code", res.StatusCode)
return "", err
}

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(body), nil
}

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 {
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
}

// 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 != ""
}
167 changes: 167 additions & 0 deletions client/fingerprint/env_digitalocean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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"
"github.com/stretchr/testify/assert"
)

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")
}
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
}

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 DO 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)
assert.NoError(t, err)
assert.True(t, response.Detected, "expected response to be applicable")

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)
}

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"} {
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"
}
]
}
`
7 changes: 4 additions & 3 deletions client/fingerprint/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)

Expand Down