diff --git a/client/fingerprint/env_aws.go b/client/fingerprint/env_aws.go index 0bf92410cde..a65933a1e61 100644 --- a/client/fingerprint/env_aws.go +++ b/client/fingerprint/env_aws.go @@ -62,12 +62,12 @@ var ec2InstanceSpeedMap = map[string]int{ "d2.8xlarge": 10000, } -// EnvAWSFingerprint is used to fingerprint the CPU +// EnvAWSFingerprint is used to fingerprint AWS metadata type EnvAWSFingerprint struct { logger *log.Logger } -// NewEnvAWSFingerprint is used to create a CPU fingerprint +// NewEnvAWSFingerprint is used to create a fingerprint from AWS metadata func NewEnvAWSFingerprint(logger *log.Logger) Fingerprint { f := &EnvAWSFingerprint{logger: logger} return f @@ -176,6 +176,11 @@ func isAWS() bool { } defer resp.Body.Close() + if resp.StatusCode >= 400 { + // URL not found, which indicates that this isn't AWS + return false + } + instanceID, err := ioutil.ReadAll(resp.Body) if err != nil { log.Printf("[ERR] fingerprint.env_aws: Error reading AWS Instance ID, skipping") diff --git a/client/fingerprint/env_aws_test.go b/client/fingerprint/env_aws_test.go index acc70e689a1..e8494c2633f 100644 --- a/client/fingerprint/env_aws_test.go +++ b/client/fingerprint/env_aws_test.go @@ -13,6 +13,7 @@ import ( ) func TestEnvAWSFingerprint_nonAws(t *testing.T) { + os.Setenv("AWS_ENV_URL", "http://127.0.0.1/latest/meta-data/") f := NewEnvAWSFingerprint(testLogger()) node := &structs.Node{ Attributes: make(map[string]string), @@ -199,6 +200,7 @@ func TestNetworkFingerprint_AWS(t *testing.T) { } func TestNetworkFingerprint_notAWS(t *testing.T) { + os.Setenv("AWS_ENV_URL", "http://127.0.0.1/latest/meta-data/") f := NewEnvAWSFingerprint(testLogger()) node := &structs.Node{ Attributes: make(map[string]string), diff --git a/client/fingerprint/env_gce.go b/client/fingerprint/env_gce.go new file mode 100644 index 00000000000..b20978db008 --- /dev/null +++ b/client/fingerprint/env_gce.go @@ -0,0 +1,229 @@ +package fingerprint + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/hashicorp/nomad/client/config" + "github.com/hashicorp/nomad/nomad/structs" +) + +// This is where the GCE metadata server normally resides. We hardcode the +// "instance" path as well since it's the only one we access here. +const DEFAULT_GCE_URL = "http://169.254.169.254/computeMetadata/v1/instance/" + +type GCEMetadataNetworkInterface struct { + AccessConfigs []struct { + ExternalIp string + Type string + } + ForwardedIps []string + Ip string + Network string +} + +type ReqError struct { + StatusCode int +} + +func (e ReqError) Error() string { + return http.StatusText(e.StatusCode) +} + +func lastToken(s string) string { + index := strings.LastIndex(s, "/") + return s[index+1:] +} + +// EnvGCEFingerprint is used to fingerprint GCE metadata +type EnvGCEFingerprint struct { + client *http.Client + logger *log.Logger + metadataURL string +} + +// NewEnvGCEFingerprint is used to create a fingerprint from GCE metadata +func NewEnvGCEFingerprint(logger *log.Logger) Fingerprint { + // Read the internal metadata URL from the environment, allowing test files to + // provide their own + metadataURL := os.Getenv("GCE_ENV_URL") + if metadataURL == "" { + metadataURL = DEFAULT_GCE_URL + } + + // assume 2 seconds is enough time for inside GCE network + client := &http.Client{ + Timeout: 2 * time.Second, + } + + return &EnvGCEFingerprint{ + client: client, + logger: logger, + metadataURL: metadataURL, + } +} + +func (f *EnvGCEFingerprint) Get(attribute string, recursive bool) (string, error) { + reqUrl := f.metadataURL + attribute + if recursive { + reqUrl = reqUrl + "?recursive=true" + } + + parsedUrl, err := url.Parse(reqUrl) + if err != nil { + return "", err + } + + req := &http.Request{ + Method: "GET", + URL: parsedUrl, + Header: http.Header{ + "Metadata-Flavor": []string{"Google"}, + }, + } + + res, err := f.client.Do(req) + if err != nil { + return "", err + } + + resp, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + f.logger.Printf("[ERR]: fingerprint.env_gce: Error reading response body for GCE %s", attribute) + return "", err + } + + if res.StatusCode >= 400 { + return "", ReqError{res.StatusCode} + } + + return string(resp), nil +} + +func checkError(err error, logger *log.Logger, desc string) error { + // If it's a URL error, assume we're not actually in an GCE environment. + // To the outer layers, this isn't an error so return nil. + if _, ok := err.(*url.Error); ok { + logger.Printf("[ERR] fingerprint.env_gce: Error querying GCE " + desc + ", skipping") + return nil + } + // Otherwise pass the error through. + return err +} + +func (f *EnvGCEFingerprint) Fingerprint(cfg *config.Config, node *structs.Node) (bool, error) { + if !f.isGCE() { + return false, nil + } + + if node.Links == nil { + node.Links = make(map[string]string) + } + + keys := []string{ + "hostname", + "id", + "cpu-platform", + "scheduling/automatic-restart", + "scheduling/on-host-maintenance", + } + for _, k := range keys { + value, err := f.Get(k, false) + if err != nil { + return false, checkError(err, f.logger, k) + } + + // assume we want blank entries + key := strings.Replace(k, "/", ".", -1) + node.Attributes["platform.gce."+key] = strings.Trim(string(value), "\n") + } + + // These keys need everything before the final slash removed to be usable. + keys = []string{ + "machine-type", + "zone", + } + for _, k := range keys { + value, err := f.Get(k, false) + if err != nil { + return false, checkError(err, f.logger, k) + } + + node.Attributes["platform.gce."+k] = strings.Trim(lastToken(value), "\n") + } + + // Get internal and external IPs (if they exist) + value, err := f.Get("network-interfaces/", true) + var interfaces []GCEMetadataNetworkInterface + if err := json.Unmarshal([]byte(value), &interfaces); err != nil { + f.logger.Printf("[WARN] fingerprint.env_gce: Error decoding network interface information: %s", err.Error()) + } + + for _, intf := range interfaces { + prefix := "platform.gce.network." + lastToken(intf.Network) + node.Attributes[prefix] = "true" + node.Attributes[prefix+".ip"] = strings.Trim(intf.Ip, "\n") + for index, accessConfig := range intf.AccessConfigs { + node.Attributes[prefix+".external-ip."+strconv.Itoa(index)] = accessConfig.ExternalIp + } + } + + var tagList []string + value, err = f.Get("tags", false) + if err != nil { + return false, checkError(err, f.logger, "tags") + } + if err := json.Unmarshal([]byte(value), &tagList); err != nil { + f.logger.Printf("[WARN] fingerprint.env_gce: Error decoding instance tags: %s", err.Error()) + } + for _, tag := range tagList { + node.Attributes["platform.gce.tag."+tag] = "true" + } + + var attrDict map[string]string + value, err = f.Get("attributes/", true) + if err != nil { + return false, checkError(err, f.logger, "attributes/") + } + if err := json.Unmarshal([]byte(value), &attrDict); err != nil { + f.logger.Printf("[WARN] fingerprint.env_gce: Error decoding instance attributes: %s", err.Error()) + } + for k, v := range attrDict { + node.Attributes["platform.gce.attr."+k] = strings.Trim(v, "\n") + } + + // populate Links + node.Links["gce"] = node.Attributes["platform.gce.id"] + + return true, nil +} + +func (f *EnvGCEFingerprint) isGCE() bool { + // TODO: better way to detect GCE? + + // Query the metadata url for the machine type, to verify we're on GCE + machineType, err := f.Get("machine-type", false) + if err != nil { + if re, ok := err.(ReqError); !ok || re.StatusCode != 404 { + // If it wasn't a 404 error, print an error message. + f.logger.Printf("[ERR] fingerprint.env_gce: Error querying GCE Metadata URL, skipping") + } + return false + } + + match, err := regexp.MatchString("projects/.+/machineTypes/.+", machineType) + if !match { + return false + } + + return true +} diff --git a/client/fingerprint/env_gce_test.go b/client/fingerprint/env_gce_test.go new file mode 100644 index 00000000000..159563576b8 --- /dev/null +++ b/client/fingerprint/env_gce_test.go @@ -0,0 +1,193 @@ +package fingerprint + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/hashicorp/nomad/client/config" + "github.com/hashicorp/nomad/nomad/structs" +) + +func TestGCEFingerprint_nonGCE(t *testing.T) { + os.Setenv("GCE_ENV_URL", "http://127.0.0.1/computeMetadata/v1/instance/") + f := NewEnvGCEFingerprint(testLogger()) + node := &structs.Node{ + Attributes: make(map[string]string), + } + + ok, err := f.Fingerprint(&config.Config{}, node) + if err != nil { + t.Fatalf("err: %v", err) + } + + if ok { + t.Fatalf("Should be false without test server") + } +} + +func testFingerprint_GCE(t *testing.T, withExternalIp bool) { + node := &structs.Node{ + Attributes: make(map[string]string), + } + + // configure mock server with fixture routes, data + routes := routes{} + if err := json.Unmarshal([]byte(GCE_routes), &routes); err != nil { + t.Fatalf("Failed to unmarshal JSON in GCE ENV test: %s", err) + } + networkEndpoint := &endpoint{ + Uri: "/computeMetadata/v1/instance/network-interfaces/?recursive=true", + ContentType: "application/json", + } + if withExternalIp { + networkEndpoint.Body = `[{"accessConfigs":[{"externalIp":"104.44.55.66","type":"ONE_TO_ONE_NAT"},{"externalIp":"104.44.55.67","type":"ONE_TO_ONE_NAT"}],"forwardedIps":[],"ip":"10.240.0.5","network":"projects/555555/networks/default"}]` + } else { + networkEndpoint.Body = `[{"accessConfigs":[],"forwardedIps":[],"ip":"10.240.0.5","network":"projects/555555/networks/default"}]` + } + routes.Endpoints = append(routes.Endpoints, networkEndpoint) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + value, ok := r.Header["Metadata-Flavor"] + if !ok { + t.Fatal("Metadata-Flavor not present in HTTP request header") + } + if value[0] != "Google" { + t.Fatalf("Expected Metadata-Flavor Google, saw %s", value[0]) + } + + found := false + for _, e := range routes.Endpoints { + if r.RequestURI == 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("GCE_ENV_URL", ts.URL+"/computeMetadata/v1/instance/") + f := NewEnvGCEFingerprint(testLogger()) + + ok, err := f.Fingerprint(&config.Config{}, node) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !ok { + t.Fatalf("should apply") + } + + keys := []string{ + "platform.gce.id", + "platform.gce.hostname", + "platform.gce.zone", + "platform.gce.machine-type", + "platform.gce.zone", + "platform.gce.tag.abc", + "platform.gce.tag.def", + "platform.gce.attr.ghi", + "platform.gce.attr.jkl", + } + + for _, k := range keys { + assertNodeAttributeContains(t, node, k) + } + + if len(node.Links) == 0 { + t.Fatalf("Empty links for Node in GCE Fingerprint test") + } + + // Make sure Links contains the GCE ID. + for _, k := range []string{"gce"} { + assertNodeLinksContains(t, node, k) + } + + assertNodeAttributeEquals(t, node, "platform.gce.id", "12345") + assertNodeAttributeEquals(t, node, "platform.gce.hostname", "instance-1.c.project.internal") + assertNodeAttributeEquals(t, node, "platform.gce.zone", "us-central1-f") + assertNodeAttributeEquals(t, node, "platform.gce.machine-type", "n1-standard-1") + assertNodeAttributeEquals(t, node, "platform.gce.network.default", "true") + assertNodeAttributeEquals(t, node, "platform.gce.network.default.ip", "10.240.0.5") + if withExternalIp { + assertNodeAttributeEquals(t, node, "platform.gce.network.default.external-ip.0", "104.44.55.66") + assertNodeAttributeEquals(t, node, "platform.gce.network.default.external-ip.1", "104.44.55.67") + } else if _, ok := node.Attributes["platform.gce.network.default.external-ip.0"]; ok { + t.Fatal("platform.gce.network.default.external-ip is set without an external IP") + } + + assertNodeAttributeEquals(t, node, "platform.gce.scheduling.automatic-restart", "TRUE") + assertNodeAttributeEquals(t, node, "platform.gce.scheduling.on-host-maintenance", "MIGRATE") + assertNodeAttributeEquals(t, node, "platform.gce.cpu-platform", "Intel Ivy Bridge") + assertNodeAttributeEquals(t, node, "platform.gce.tag.abc", "true") + assertNodeAttributeEquals(t, node, "platform.gce.tag.def", "true") + assertNodeAttributeEquals(t, node, "platform.gce.attr.ghi", "111") + assertNodeAttributeEquals(t, node, "platform.gce.attr.jkl", "222") +} + +const GCE_routes = ` +{ + "endpoints": [ + { + "uri": "/computeMetadata/v1/instance/id", + "content-type": "text/plain", + "body": "12345" + }, + { + "uri": "/computeMetadata/v1/instance/hostname", + "content-type": "text/plain", + "body": "instance-1.c.project.internal" + }, + { + "uri": "/computeMetadata/v1/instance/zone", + "content-type": "text/plain", + "body": "projects/555555/zones/us-central1-f" + }, + { + "uri": "/computeMetadata/v1/instance/machine-type", + "content-type": "text/plain", + "body": "projects/555555/machineTypes/n1-standard-1" + }, + { + "uri": "/computeMetadata/v1/instance/tags", + "content-type": "application/json", + "body": "[\"abc\", \"def\"]" + }, + { + "uri": "/computeMetadata/v1/instance/attributes/?recursive=true", + "content-type": "application/json", + "body": "{\"ghi\":\"111\",\"jkl\":\"222\"}" + }, + { + "uri": "/computeMetadata/v1/instance/scheduling/automatic-restart", + "content-type": "text/plain", + "body": "TRUE" + }, + { + "uri": "/computeMetadata/v1/instance/scheduling/on-host-maintenance", + "content-type": "text/plain", + "body": "MIGRATE" + }, + { + "uri": "/computeMetadata/v1/instance/cpu-platform", + "content-type": "text/plain", + "body": "Intel Ivy Bridge" + } + ] +} +` + +func TestFingerprint_GCEWithExternalIp(t *testing.T) { + testFingerprint_GCE(t, true) +} + +func TestFingerprint_GCEWithoutExternalIp(t *testing.T) { + testFingerprint_GCE(t, false) +} diff --git a/client/fingerprint/fingerprint.go b/client/fingerprint/fingerprint.go index ce69a8ac94c..4a42057b297 100644 --- a/client/fingerprint/fingerprint.go +++ b/client/fingerprint/fingerprint.go @@ -18,6 +18,7 @@ var BuiltinFingerprints = []string{ "storage", "network", "env_aws", + "env_gce", } // builtinFingerprintMap contains the built in registered fingerprints @@ -30,6 +31,7 @@ var builtinFingerprintMap = map[string]Factory{ "storage": NewStorageFingerprint, "network": NewNetworkFingerprinter, "env_aws": NewEnvAWSFingerprint, + "env_gce": NewEnvGCEFingerprint, } // NewFingerprint is used to instantiate and return a new fingerprint