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 eb26594
Show file tree
Hide file tree
Showing 14 changed files with 715 additions and 0 deletions.
8 changes: 8 additions & 0 deletions apmtest/env.go
@@ -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
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
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
@@ -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
@@ -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
@@ -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
@@ -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
}

0 comments on commit eb26594

Please sign in to comment.