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
Changes from 1 commit
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
180 changes: 180 additions & 0 deletions client/fingerprint/env_digitalocean.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

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