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 2 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
173 changes: 173 additions & 0 deletions client/fingerprint/env_digitalocean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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 api normally resides.
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
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: "GET",
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
URL: parsedURL,
Header: http.Header{
"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)
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
return "", err
} else if res.StatusCode != http.StatusOK {
f.logger.Debug("could not read value for attribute", "attribute", attribute, "resp_code", res.StatusCode)
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
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

}

resp, err := ioutil.ReadAll(res.Body)
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
res.Body.Close()
if err != nil {
f.logger.Error("error reading response body for DigitalOcean attribute", "attribute", attribute, "error", err)
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
return "", err
}

if res.StatusCode >= 400 {
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
return "", ReqError{res.StatusCode}
}

return string(resp), nil
}

func checkDigitalOceanError(err error, logger log.Logger, desc string) error {
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
// 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)
kevinschoonover marked this conversation as resolved.
Show resolved Hide resolved
} 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 != ""
}
173 changes: 173 additions & 0 deletions client/fingerprint/env_digitalocean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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")
}
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)
if err != nil {
t.Fatalf("err: %v", err)
}

if !response.Detected {
t.Fatalf("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)
}

if len(response.Links) == 0 {
t.Fatalf("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