Skip to content

Commit

Permalink
Fetch cloud metadata
Browse files Browse the repository at this point in the history
Based on the Python reference implementation:
elastic/apm-agent-python#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.
  • Loading branch information
axw committed Sep 30, 2020
1 parent 4f71814 commit fe4d0da
Show file tree
Hide file tree
Showing 14 changed files with 665 additions and 0 deletions.
8 changes: 8 additions & 0 deletions apmtest/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package apmtest

import "os"

func init() {
// Disable cloud metadata sniffing by default in tests.
os.Setenv("ELASTIC_APM_CLOUD_PROVIDER", "none")
}
1 change: 1 addition & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
83 changes: 83 additions & 0 deletions internal/apmcloudutil/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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
}
80 changes: 80 additions & 0 deletions internal/apmcloudutil/aws_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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
}
61 changes: 61 additions & 0 deletions internal/apmcloudutil/azure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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
}
63 changes: 63 additions & 0 deletions internal/apmcloudutil/azure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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
}
Loading

0 comments on commit fe4d0da

Please sign in to comment.