Skip to content

Commit

Permalink
Cloud Metadata Fetching (#1937)
Browse files Browse the repository at this point in the history
* Implements: #1815

* feat: cloud metadata request

* chore: move test file

* feat: test complete

* feat: working through test failures

* fix: tests

* chore: re-org

* feat: reorg metadata

* chore: undo test removal

* chore: remove console.log

* feat: request module

* chore: moving on up

* chore: instrumentation path

* chore: bringing over test server fixtures from other branch

* chore: lint cleanup post-elrequest

* fix: failure handlers for request

* chore: lint

* feat: add cli interface for quick testing on cloud servers

* chore: exit codes for cli

* feat: fetch metadata for all, corrdinate messages

* feat: error handling for non-json responses

* feat: adding agent configuration of cloud provider and tests for same

* chore: module constants for timeouts

* ci: new test server endpoint for v2 amazon

* feat: add v2 server tests and implementation

* fix: error handling

* chore: lint

* test: full testing of unexpected responses from metadata server

* chore: lint

* test: tests for coordination object

* chore: rename to CallbackCoordination

* chore: rename library file

* fix: cleanup timeout in coordination

* chore: lint

* fix: better pattern for no double callbacks on errors

* feat: logging, comment proofread

* feat: APM Server wants strings for everything, add type casting/checking

* chore: fix port

* chore: lint

* chore: changelog

* chore: adjust log level

* chore: add link to issue

* chore: nits -- variable name and error type export

* chore: lint, remove unused data

* fix: better pattern for request callback

* chore: variable rename

* chore: change from passing entire agent to just passing provider name

* chore: use logger arg instead of global agent instance

* chore: for loop style

* chore: cast with string objects instead of concats

* chore: not new String, String

* chore: lint

* chore: logger into other tests

* chore: more getLogger calls

* chore: lint

* feat: unified IMDSv1 and IMDSv2 method

* feat: unify aws APIs

* fix: avoid catching the callback's exceptions

* fix: removes instances of "callback function in a try" anti-pattern

* feat: refactor to use URLs, move constants into modules

* fix: 32 is not x32

* chore: lint

* fix: node 8 and URL

* feat: config normalization and testing

* feat: improve API of getMetadata... methods, baseApiOverride param

* fix: log a "shouldn't happen" situation

* chore: update comments

* chore: err vs. error

* chore: no-op logger

* chore: one last logger

* chore: lint

* chore: module path
  • Loading branch information
astorm authored Feb 1, 2021
1 parent 0eb09de commit d48e128
Show file tree
Hide file tree
Showing 14 changed files with 2,141 additions and 3 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ Notes:
allow numeric and boolean labels. Stringify defaults to true for backwards
compatibility -- stringification will be removed in a future major version.
* feat: added support for cloud metadata fetching
Implements: https://github.com/elastic/apm-agent-nodejs/issues/1815
Agent now collects information about its cloud environment and includes this data in the APM Server's metadata payload. See
https://github.com/elastic/apm/blob/3acd10afa0a9d3510e819229dfce0764133083d3/specs/agents/metadata.md#cloud-provider-metadata
for more information.
[float]
===== Bug fixes
Expand Down
159 changes: 159 additions & 0 deletions lib/cloud-metadata/aws.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use strict'
const URL = require('url').URL
const { httpRequest } = require('../http-request')
const DEFAULT_BASE_URL = new URL('/', 'http://169.254.169.254:80')

function sanitizeHttpHeaderValue (value) {
// no newlines, carriage returns, or ascii characters outside of 32 (\x20) - 127 (\x7F)
const newValue = value.replace(/[\r\n]/g, '').replace(/[^\x20-\x7F]/g, '')
return newValue
}

/**
* Logic for making request to /latest/dynamic/instance-identity/document
*
* The headers parameter allow us to, if needed, set the IMDSv2 token
*/
function getMetadataAwsWithHeaders (baseUrl, headers, connectTimeoutMs, resTimeoutMs, logger, cb) {
const options = {
method: 'GET',
timeout: resTimeoutMs,
connectTimeout: connectTimeoutMs
}
if (headers) {
options.headers = headers
}
const url = baseUrl + 'latest/dynamic/instance-identity/document'
const req = httpRequest(
url,
options,
function (res) {
const finalData = []
res.on('data', function (data) {
finalData.push(data)
})

res.on('end', function (data) {
let result
try {
result = formatMetadataStringIntoObject(finalData.join(''))
} catch (err) {
logger.trace('aws metadata server responded, but there was an ' +
'error parsing the result: %o', err)
cb(err)
return
}
cb(null, result)
})
}
)

req.on('timeout', function () {
req.destroy(new Error('request to metadata server timed out'))
})

req.on('connectTimeout', function () {
req.destroy(new Error('could not ping metadata server'))
})

req.on('error', function (err) {
cb(err)
})

req.end()
}

/**
* Fetches metadata from either an IMDSv2 or IMDSv1 endpoint
*
* Attempts to fetch a token for an IMDSv2 service call. If this call
* is a success, then we call the instance endpoint with the token. If
* this call fails, then we call the instance endpoint without the token.
*
* A "success" to the token endpoint means an HTTP status code of 200
* and a non-zero-length return value for the token.
*
* https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
*/
function getMetadataAws (connectTimeoutMs, resTimeoutMs, logger, baseUrlOverride, cb) {
const baseUrl = baseUrlOverride || DEFAULT_BASE_URL
const url = baseUrl + 'latest/api/token'
const options = {
method: 'PUT',
headers: {
'X-aws-ec2-metadata-token-ttl-seconds': 300
},
timeout: resTimeoutMs,
connectTimeout: connectTimeoutMs
}
const req = httpRequest(
url,
options,
function (res) {
const finalData = []
res.on('data', function (data) {
finalData.push(data)
})

res.on('end', function () {
const token = sanitizeHttpHeaderValue(finalData.join(''))
const headers = {}
if (token && res.statusCode === 200) {
// uses return value from call to latest/api/token as token,
// and takes extra steps to ensure characters are valid
headers['X-aws-ec2-metadata-token'] = token
}

getMetadataAwsWithHeaders(
baseUrl,
headers,
connectTimeoutMs,
resTimeoutMs,
logger,
cb
)
})
}
)
req.on('timeout', function () {
req.destroy(new Error('request for metadata token timed out'))
})

req.on('connectTimeout', function () {
req.destroy(new Error('socket connection to metadata token server timed out'))
})

req.on('error', function (err) {
cb(err)
})

req.end()
}

/**
* Builds metadata object
*
* Takes the response from a /latest/dynamic/instance-identity/document
* service request and formats it into the cloud metadata object
*/
function formatMetadataStringIntoObject (string) {
const data = JSON.parse(string)
const metadata = {
account: {
id: String(data.accountId)
},
instance: {
id: String(data.instanceId)
},
availability_zone: String(data.availabilityZone),
machine: {
type: String(data.instanceType)
},
provider: 'aws',
region: String(data.region)
}

return metadata
}

module.exports = { getMetadataAws }
112 changes: 112 additions & 0 deletions lib/cloud-metadata/azure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use strict'
const URL = require('url').URL
const { httpRequest } = require('../http-request')

const DEFAULT_BASE_URL = new URL('/', 'http://169.254.169.254:80')

/**
* Checks for metadata server then fetches data
*
* The getMetadataAzure method will fetch cloud metadata information
* from Amazon's IMDSv1 endpoint and return (via callback)
* the formatted metadata.
*
* Before fetching data, the server will be "pinged" by attempting
* to connect via TCP with a short timeout (`connectTimeoutMs`).
*
* https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=windows
*/
function getMetadataAzure (connectTimeoutMs, resTimeoutMs, logger, baseUrlOverride, cb) {
const baseUrl = baseUrlOverride || DEFAULT_BASE_URL
const options = {
method: 'GET',
timeout: resTimeoutMs,
connectTimeout: connectTimeoutMs,
headers: {
Metadata: 'true'
}
}

const url = baseUrl.toString() + 'metadata/instance?api-version=2020-09-01'

const req = httpRequest(
url,
options,
function (res) {
const finalData = []
res.on('data', function (data) {
finalData.push(data)
})

res.on('end', function (data) {
let result
try {
result = formatMetadataStringIntoObject(finalData.join(''))
} catch (err) {
logger.trace('azure metadata server responded, but there was an ' +
'error parsing the result: %o', err)
cb(err)
return
}
cb(null, result)
})
}
)

req.on('timeout', function () {
req.destroy(new Error('request to azure metadata server timed out'))
})

req.on('connectTimeout', function () {
req.destroy(new Error('could not ping azure metadata server'))
})

req.on('error', function (err) {
cb(err)
})

req.end()
}

/**
* Builds metadata object
*
* Takes the response from /metadata/instance?api-version=2020-09-01
* service request and formats it into the cloud metadata object
*/
function formatMetadataStringIntoObject (string) {
const metadata = {
account: {
id: null
},
instance: {
id: null,
name: null
},
project: {
name: null
},
availability_zone: null,
machine: {
type: null
},
provider: 'azure',
region: null
}
const parsed = JSON.parse(string)
if (!parsed.compute) {
return metadata
}
const data = parsed.compute
metadata.account.id = String(data.subscriptionId)
metadata.instance.id = String(data.vmId)
metadata.instance.name = String(data.name)
metadata.project.name = String(data.resourceGroupName)
metadata.availability_zone = String(data.zone)
metadata.machine.type = String(data.vmSize)
metadata.region = String(data.location)

return metadata
}

module.exports = { getMetadataAzure }
Loading

0 comments on commit d48e128

Please sign in to comment.