From 91d8f945f4625da383042b690d102ecb6795aefc Mon Sep 17 00:00:00 2001 From: Andrew Wilkins Date: Tue, 29 Sep 2020 17:16:30 +0800 Subject: [PATCH] Fetch cloud metadata Based on the Python reference implementation: https://github.com/elastic/apm-agent-python/pull/826 By default we attempt to sniff metadata for all cloud providers. This can be overridden by setting the environment variable ELASTIC_APM_CLOUD_PROVIDER to one of "none", "aws", "azure", or "gcp". We set a short (100ms) socket connect timeout, and a slightly longer overall timeout (1s) for fetching the cloud metadata. In tests we disable cloud metadata fetching by default. There are currently no functional tests, as that would rely on us running in a known cloud environment. --- apmtest/env.go | 25 +++++ config.go | 1 + docs/configuration.asciidoc | 17 ++++ internal/apmcloudutil/aws.go | 100 ++++++++++++++++++++ internal/apmcloudutil/aws_test.go | 97 +++++++++++++++++++ internal/apmcloudutil/azure.go | 78 +++++++++++++++ internal/apmcloudutil/azure_test.go | 80 ++++++++++++++++ internal/apmcloudutil/gcp.go | 106 +++++++++++++++++++++ internal/apmcloudutil/gcp_test.go | 125 +++++++++++++++++++++++++ internal/apmcloudutil/provider.go | 116 +++++++++++++++++++++++ internal/apmcloudutil/provider_test.go | 95 +++++++++++++++++++ tracer.go | 4 + tracer_test.go | 4 + utils.go | 31 ++++++ 14 files changed, 879 insertions(+) create mode 100644 apmtest/env.go create mode 100644 internal/apmcloudutil/aws.go create mode 100644 internal/apmcloudutil/aws_test.go create mode 100644 internal/apmcloudutil/azure.go create mode 100644 internal/apmcloudutil/azure_test.go create mode 100644 internal/apmcloudutil/gcp.go create mode 100644 internal/apmcloudutil/gcp_test.go create mode 100644 internal/apmcloudutil/provider.go create mode 100644 internal/apmcloudutil/provider_test.go diff --git a/apmtest/env.go b/apmtest/env.go new file mode 100644 index 000000000..20d069f2b --- /dev/null +++ b/apmtest/env.go @@ -0,0 +1,25 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmtest + +import "os" + +func init() { + // Disable cloud metadata sniffing by default in tests. + os.Setenv("ELASTIC_APM_CLOUD_PROVIDER", "none") +} diff --git a/config.go b/config.go index 9e402ee6a..10d48130b 100644 --- a/config.go +++ b/config.go @@ -57,6 +57,7 @@ const ( envCentralConfig = "ELASTIC_APM_CENTRAL_CONFIG" envBreakdownMetrics = "ELASTIC_APM_BREAKDOWN_METRICS" envUseElasticTraceparentHeader = "ELASTIC_APM_USE_ELASTIC_TRACEPARENT_HEADER" + envCloudProvider = "ELASTIC_APM_CLOUD_PROVIDER" // NOTE(axw) profiling environment variables are experimental. // They may be removed in a future minor version without being diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index b9754e4de..be22b1d21 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -593,3 +593,20 @@ https://www.w3.org/TR/trace-context-1/[W3C Trace Context] specification. When this setting is `true`, the agent will also add the header `elastic-apm-traceparent` for backwards compatibility with older versions of Elastic APM agents. + +[float] +[[config-cloud-provider]] +==== `ELASTIC_APM_CLOUD_PROVIDER` + +[options="header"] +|============ +| Environment | Default | Example +| `ELASTIC_APM_CLOUD_PROVIDER` | `"none"` | `"aws"` +|============ + +This config value allows you to specify which cloud provider should be assumed +for metadata collection. By default, the agent will use trial and error to +automatically collect the cloud metadata. + +Valid options are `"none"`, `"auto"`, `"aws"`, `"gcp"`, and `"azure"` +If this config value is set to `"none"`, then no cloud metadata will be collected. diff --git a/internal/apmcloudutil/aws.go b/internal/apmcloudutil/aws.go new file mode 100644 index 000000000..9a85a0b57 --- /dev/null +++ b/internal/apmcloudutil/aws.go @@ -0,0 +1,100 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "go.elastic.co/apm/model" +) + +const ( + ec2TokenURL = "http://169.254.169.254/latest/api/token" + ec2MetadataURL = "http://169.254.169.254/latest/dynamic/instance-identity/document" +) + +// See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html +func getAWSCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + token, err := getAWSToken(ctx, client) + if err != nil { + return err + } + + req, err := http.NewRequest("GET", ec2MetadataURL, nil) + if err != nil { + return err + } + if token != "" { + req.Header.Set("X-aws-ec2-metadata-token", token) + } + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var ec2Metadata struct { + AccountID string `json:"accountId"` + AvailabilityZone string `json:"availabilityZone"` + Region string `json:"region"` + InstanceID string `json:"instanceId"` + InstanceType string `json:"instanceType"` + } + if err := json.NewDecoder(resp.Body).Decode(&ec2Metadata); err != nil { + return err + } + + out.Region = ec2Metadata.Region + out.AvailabilityZone = ec2Metadata.AvailabilityZone + if ec2Metadata.InstanceID != "" { + out.Instance = &model.CloudInstance{ID: ec2Metadata.InstanceID} + } + if ec2Metadata.InstanceType != "" { + out.Machine = &model.CloudMachine{Type: ec2Metadata.InstanceType} + } + if ec2Metadata.AccountID != "" { + out.Account = &model.CloudAccount{ID: ec2Metadata.AccountID} + } + return nil +} + +func getAWSToken(ctx context.Context, client *http.Client) (string, error) { + req, err := http.NewRequest("PUT", ec2TokenURL, nil) + if err != nil { + return "", err + } + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "300") + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return "", err + } + defer resp.Body.Close() + token, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(token), nil +} diff --git a/internal/apmcloudutil/aws_test.go b/internal/apmcloudutil/aws_test.go new file mode 100644 index 000000000..c1d50615e --- /dev/null +++ b/internal/apmcloudutil/aws_test.go @@ -0,0 +1,97 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestAWSCloudMetadata(t *testing.T) { + srv, client := newAWSMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, AWS} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "aws", + Region: "us-east-2", + AvailabilityZone: "us-east-2a", + Instance: &model.CloudInstance{ + ID: "i-0ae894a7c1c4f2a75", + }, + Machine: &model.CloudMachine{ + Type: "t2.medium", + }, + Account: &model.CloudAccount{ + ID: "946960629917", + }, + }, out) + } +} + +func newAWSMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest/api/token": + w.Write([]byte("topsecret")) + return + case "/latest/dynamic/instance-identity/document": + token := r.Header.Get("X-Aws-Ec2-Metadata-Token") + if token != "topsecret" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("invalid token")) + return + } + break + default: + w.WriteHeader(http.StatusNotFound) + return + } + + w.Write([]byte(`{ + "accountId": "946960629917", + "architecture": "x86_64", + "availabilityZone": "us-east-2a", + "billingProducts": null, + "devpayProductCodes": null, + "marketplaceProductCodes": null, + "imageId": "ami-07c1207a9d40bc3bd", + "instanceId": "i-0ae894a7c1c4f2a75", + "instanceType": "t2.medium", + "kernelId": null, + "pendingTime": "2020-06-12T17:46:09Z", + "privateIp": "172.31.0.212", + "ramdiskId": null, + "region": "us-east-2", + "version": "2017-09-30" +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("169.254.169.254", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/azure.go b/internal/apmcloudutil/azure.go new file mode 100644 index 000000000..b4ac6af4c --- /dev/null +++ b/internal/apmcloudutil/azure.go @@ -0,0 +1,78 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "go.elastic.co/apm/model" +) + +const ( + azureMetadataURL = "http://169.254.169.254/metadata/instance/compute?api-version=2019-08-15" +) + +// See: https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service +func getAzureCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + req, err := http.NewRequest("GET", azureMetadataURL, nil) + if err != nil { + return err + } + req.Header.Set("Metadata", "true") + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var azureMetadata struct { + Location string `json:"location"` + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + SubscriptionID string `json:"subscriptionId"` + VMID string `json:"vmId"` + VMSize string `json:"vmSize"` + Zone string `json:"zone"` + } + if err := json.NewDecoder(resp.Body).Decode(&azureMetadata); err != nil { + return err + } + + out.Region = azureMetadata.Location + out.AvailabilityZone = azureMetadata.Zone + if azureMetadata.VMID != "" || azureMetadata.Name != "" { + out.Instance = &model.CloudInstance{ID: azureMetadata.VMID, Name: azureMetadata.Name} + } + if azureMetadata.VMSize != "" { + out.Machine = &model.CloudMachine{Type: azureMetadata.VMSize} + } + if azureMetadata.ResourceGroupName != "" { + out.Project = &model.CloudProject{Name: azureMetadata.ResourceGroupName} + } + if azureMetadata.SubscriptionID != "" { + out.Account = &model.CloudAccount{ID: azureMetadata.SubscriptionID} + } + return nil +} diff --git a/internal/apmcloudutil/azure_test.go b/internal/apmcloudutil/azure_test.go new file mode 100644 index 000000000..6172d8e56 --- /dev/null +++ b/internal/apmcloudutil/azure_test.go @@ -0,0 +1,80 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestAzureCloudMetadata(t *testing.T) { + srv, client := newAzureMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, Azure} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "azure", + Region: "westus2", + Instance: &model.CloudInstance{ + ID: "e11ebedc-019d-427f-84dd-56cd4388d3a8", + Name: "basepi-test", + }, + Machine: &model.CloudMachine{ + Type: "Standard_D2s_v3", + }, + Project: &model.CloudProject{ + Name: "basepi-testing", + }, + Account: &model.CloudAccount{ + ID: "7657426d-c4c3-44ac-88a2-3b2cd59e6dba", + }, + }, out) + } +} + +func newAzureMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata/instance/compute" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "location": "westus2", + "name": "basepi-test", + "resourceGroupName": "basepi-testing", + "subscriptionId": "7657426d-c4c3-44ac-88a2-3b2cd59e6dba", + "vmId": "e11ebedc-019d-427f-84dd-56cd4388d3a8", + "vmScaleSetName": "", + "vmSize": "Standard_D2s_v3", + "zone": "" +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("169.254.169.254", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/gcp.go b/internal/apmcloudutil/gcp.go new file mode 100644 index 000000000..a88fe4df8 --- /dev/null +++ b/internal/apmcloudutil/gcp.go @@ -0,0 +1,106 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "strconv" + "strings" + + "go.elastic.co/apm/model" +) + +const ( + gcpMetadataURL = "http://metadata.google.internal/computeMetadata/v1/?recursive=true" +) + +// See: https://cloud.google.com/compute/docs/storing-retrieving-metadata +func getGCPCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + req, err := http.NewRequest("GET", gcpMetadataURL, nil) + if err != nil { + return err + } + req.Header.Set("Metadata-Flavor", "Google") + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var gcpMetadata struct { + Instance struct { + // ID may be an integer or a hex string. + ID interface{} `json:"id"` + MachineType string `json:"machineType"` + Name string `json:"name"` + Zone string `json:"zone"` + } `json:"instance"` + Project struct { + NumericProjectID *int `json:"numericProjectId"` + ProjectID string `json:"projectId"` + } `json:"project"` + } + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&gcpMetadata); err != nil { + return err + } + + out.Region, out.AvailabilityZone = splitGCPZone(gcpMetadata.Instance.Zone) + if gcpMetadata.Instance.ID != nil || gcpMetadata.Instance.Name != "" { + out.Instance = &model.CloudInstance{ + Name: gcpMetadata.Instance.Name, + } + if gcpMetadata.Instance.ID != nil { + out.Instance.ID = fmt.Sprint(gcpMetadata.Instance.ID) + } + } + if gcpMetadata.Instance.MachineType != "" { + out.Machine = &model.CloudMachine{Type: splitGCPMachineType(gcpMetadata.Instance.MachineType)} + } + if gcpMetadata.Project.NumericProjectID != nil || gcpMetadata.Project.ProjectID != "" { + out.Project = &model.CloudProject{Name: gcpMetadata.Project.ProjectID} + if gcpMetadata.Project.NumericProjectID != nil { + out.Project.ID = strconv.Itoa(*gcpMetadata.Project.NumericProjectID) + } + } + return nil +} + +func splitGCPZone(s string) (region, zone string) { + // Format: "projects/projectnum/zones/zone" + zone = path.Base(s) + if sep := strings.LastIndex(zone, "-"); sep != -1 { + region = zone[:sep] + } + return region, zone +} + +func splitGCPMachineType(s string) string { + // Format: projects/513326162531/machineTypes/n1-standard-1 + return path.Base(s) +} diff --git a/internal/apmcloudutil/gcp_test.go b/internal/apmcloudutil/gcp_test.go new file mode 100644 index 000000000..ba3bd4203 --- /dev/null +++ b/internal/apmcloudutil/gcp_test.go @@ -0,0 +1,125 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestGCPCloudMetadata(t *testing.T) { + t.Run("gce", func(t *testing.T) { + srv, client := newGCEMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, GCP} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "gcp", + Region: "us-west3", + AvailabilityZone: "us-west3-a", + Instance: &model.CloudInstance{ + ID: "4306570268266786072", + Name: "basepi-test", + }, + Machine: &model.CloudMachine{ + Type: "n1-standard-1", + }, + Project: &model.CloudProject{ + ID: "513326162531", + Name: "elastic-apm", + }, + }, out) + } + }) + + t.Run("cloudrun", func(t *testing.T) { + srv, client := newGoogleCloudRunMetadataServer() + defer srv.Close() + + var out model.Cloud + var logger testLogger + assert.True(t, GCP.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "gcp", + Region: "australia-southeast1", + AvailabilityZone: "australia-southeast1-1", + Instance: &model.CloudInstance{ + ID: "00bf4bf02ddbda278fb9b4d70365018bd18a7d3ea42991e2cb03320b48a72b69b6d3765ff526347d7b8e0934dda4591cb1be3ead93086f0b390187fae88ee7cf8acdae7383", + }, + Project: &model.CloudProject{ + ID: "513326162531", + Name: "elastic-apm", + }, + }, out) + }) +} + +func newGCEMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/computeMetadata/v1/" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "instance": { + "id": 4306570268266786072, + "machineType": "projects/513326162531/machineTypes/n1-standard-1", + "name": "basepi-test", + "zone": "projects/513326162531/zones/us-west3-a" + }, + "project": {"numericProjectId": 513326162531, "projectId": "elastic-apm"} +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("metadata.google.internal", srv.Listener.Addr().String())} + return srv, client +} + +func newGoogleCloudRunMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/computeMetadata/v1/" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "instance": { + "id": "00bf4bf02ddbda278fb9b4d70365018bd18a7d3ea42991e2cb03320b48a72b69b6d3765ff526347d7b8e0934dda4591cb1be3ead93086f0b390187fae88ee7cf8acdae7383", + "region":"projects/513326162531/regions/australia-southeast1", + "zone":"projects/513326162531/zones/australia-southeast1-1" + }, + "project": { + "numericProjectId": 513326162531, + "projectId": "elastic-apm" + } +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("metadata.google.internal", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/provider.go b/internal/apmcloudutil/provider.go new file mode 100644 index 000000000..3ad7527b0 --- /dev/null +++ b/internal/apmcloudutil/provider.go @@ -0,0 +1,116 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "go.elastic.co/apm/internal/apmlog" + "go.elastic.co/apm/model" +) + +// defaultClient is essentially the same as http.DefaultTransport, except +// that it has a short (100ms) dial timeout to avoid delaying startup. +var defaultClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 100 * time.Millisecond, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + }, +} + +// Provider identifies the cloud provider. +type Provider string + +const ( + // None is a pseudo cloud provider which disables fetching of + // cloud metadata. + None Provider = "none" + + // Auto is a pseudo cloud provider which uses trial-and-error to + // fetch cloud metadata from all supported clouds. + Auto Provider = "auto" + + // AWS represents the Amazon Web Services (EC2) cloud provider. + AWS Provider = "aws" + + // Azure represents the Microsoft Azure cloud provider. + Azure Provider = "azure" + + // GCP represents the Google Cloud Platform cloud provider. + GCP Provider = "gcp" +) + +// ParseProvider parses the provider name "s", returning the relevant Provider. +// +// If the provider name is unknown, None will be returned with an error. +func ParseProvider(s string) (Provider, error) { + switch Provider(s) { + case Auto, AWS, Azure, GCP, None: + return Provider(s), nil + } + return None, fmt.Errorf("unknown cloud provider %q", s) +} + +// GetCloudMetadata attempts to fetch cloud metadata for cloud provider p, +// storing it into out and returning a boolean indicating that the metadata +// was successfully retrieved. +// +// It is the caller's responsibility to set a reasonable timeout, to ensure +// requests do not block normal operation in non-cloud environments. +func (p Provider) GetCloudMetadata(ctx context.Context, logger apmlog.Logger, out *model.Cloud) bool { + return p.getCloudMetadata(ctx, defaultClient, logger, out) +} + +func (p Provider) getCloudMetadata(ctx context.Context, client *http.Client, logger apmlog.Logger, out *model.Cloud) bool { + if p == None { + return false + } + for _, provider := range []Provider{AWS, Azure, GCP} { + if p != Auto && p != provider { + continue + } + var err error + switch provider { + case AWS: + err = getAWSCloudMetadata(ctx, client, out) + case Azure: + err = getAzureCloudMetadata(ctx, client, out) + case GCP: + err = getGCPCloudMetadata(ctx, client, out) + } + if err == nil { + out.Provider = string(provider) + return true + } else if p != Auto { + if logger != nil { + logger.Warningf("cloud provider %q specified, but cloud metadata could not be retrieved: %s", p, err) + } + return false + } + } + return false +} diff --git a/internal/apmcloudutil/provider_test.go b/internal/apmcloudutil/provider_test.go new file mode 100644 index 000000000..bf970f4c3 --- /dev/null +++ b/internal/apmcloudutil/provider_test.go @@ -0,0 +1,95 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package apmcloudutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/internal/apmlog" + "go.elastic.co/apm/model" +) + +func TestAutoProviderAllFail(t *testing.T) { + var out model.Cloud + var logger testLogger + client := &http.Client{Transport: newTargetedRoundTripper("", "testing.invalid")} + assert.False(t, Auto.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) +} + +func TestNone(t *testing.T) { + type wrappedRoundTripper struct { + http.RoundTripper + } + var out model.Cloud + var logger testLogger + client := &http.Client{Transport: &wrappedRoundTripper{nil /*panics if called*/}} + assert.False(t, None.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) +} + +// newTargetedRoundTripper returns a net/http.RoundTripper which wraps net/http.DefaultTransport, +// rewriting requests for host to be sent to target, and causing all other requests to fail. +func newTargetedRoundTripper(host, target string) http.RoundTripper { + return &targetedRoundTripper{ + Transport: http.DefaultTransport.(*http.Transport), + host: host, + target: target, + } +} + +type targetedRoundTripper struct { + *http.Transport + host string + target string +} + +func (rt *targetedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != rt.host { + port, err := strconv.Atoi(req.URL.Port()) + if err != nil { + port = 80 + } + return nil, &net.OpError{ + Op: "dial", + Net: "tcp", + Addr: &net.TCPAddr{IP: net.ParseIP(req.URL.Hostname()), Port: port}, + Err: errors.New("connect: no route to host"), + } + } + req.URL.Host = rt.target + return rt.Transport.RoundTrip(req) +} + +type testLogger struct { + apmlog.Logger // panic on unexpected method calls + buf bytes.Buffer +} + +func (tl *testLogger) Warningf(format string, args ...interface{}) { + fmt.Fprintf(&tl.buf, "[warning] "+format, args...) +} diff --git a/tracer.go b/tracer.go index f18e53ae2..f82d9fecd 100644 --- a/tracer.go +++ b/tracer.go @@ -1188,6 +1188,10 @@ func (t *Tracer) encodeRequestMetadata(json *fastjson.Writer) { t.process.MarshalFastJSON(json) json.RawString(`,"service":`) service.MarshalFastJSON(json) + if cloud := getCloudMetadata(); cloud != nil { + json.RawString(`,"cloud":`) + cloud.MarshalFastJSON(json) + } if len(globalLabels) > 0 { json.RawString(`,"labels":`) globalLabels.MarshalFastJSON(json) diff --git a/tracer_test.go b/tracer_test.go index eb527a982..871267cdc 100644 --- a/tracer_test.go +++ b/tracer_test.go @@ -401,6 +401,10 @@ func TestTracerMetadata(t *testing.T) { require.NotNil(t, system.Container) assert.Equal(t, container, system.Container) } + + // Cloud metadata is disabled by apmtest by default. + assert.Equal(t, "none", os.Getenv("ELASTIC_APM_CLOUD_PROVIDER")) + assert.Zero(t, recorder.CloudMetadata()) } func TestTracerKubernetesMetadata(t *testing.T) { diff --git a/utils.go b/utils.go index ae24404e3..7f56be7ec 100644 --- a/utils.go +++ b/utils.go @@ -18,6 +18,7 @@ package apm import ( + "context" "fmt" "math/rand" "os" @@ -26,11 +27,14 @@ import ( "regexp" "runtime" "strings" + "sync" "time" "github.com/pkg/errors" + "go.elastic.co/apm/internal/apmcloudutil" "go.elastic.co/apm/internal/apmhostutil" + "go.elastic.co/apm/internal/apmlog" "go.elastic.co/apm/internal/apmstrings" "go.elastic.co/apm/model" ) @@ -42,6 +46,9 @@ var ( goRuntime = model.Runtime{Name: runtime.Compiler, Version: runtime.Version()} localSystem model.System + cloudMetadataOnce sync.Once + cloudMetadata *model.Cloud + serviceNameInvalidRegexp = regexp.MustCompile("[^" + serviceNameValidClass + "]") labelKeyReplacer = strings.NewReplacer(`.`, `_`, `*`, `_`, `"`, `_`) @@ -160,6 +167,30 @@ func getKubernetesMetadata() *model.Kubernetes { return kubernetes } +func getCloudMetadata() *model.Cloud { + // Querying cloud metadata can block, so we don't fetch it at + // package initialisation time. Instead, we defer until it is + // first requested by the tracer. + cloudMetadataOnce.Do(func() { + logger := apmlog.DefaultLogger + provider := apmcloudutil.Auto + if str := os.Getenv(envCloudProvider); str != "" { + var err error + provider, err = apmcloudutil.ParseProvider(str) + if err != nil && logger != nil { + logger.Warningf("disabling cloud metadata: %s", envCloudProvider, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var out model.Cloud + if provider.GetCloudMetadata(ctx, logger, &out) { + cloudMetadata = &out + } + }) + return cloudMetadata +} + func cleanLabelKey(k string) string { return labelKeyReplacer.Replace(k) }