Skip to content

Commit

Permalink
Feat: Migrate EC2 Plugin Resource Detector from IMDSv1 to IMDSv2 (#1408)
Browse files Browse the repository at this point in the history
* feat: temporarily added EC2 resource detector

* feat: update EC2 Resource Detector to IDMSv2 and modified detect-resource

* chore: fix weird package dependency changing

* chore: add final line for packages

* chore: modify descriptive type for _fetchString options

* chore: remove unused variables

* chore: modified test name

* refactor: replace request header with constant variables

* chore: modify comment

* refactor: remove HTTP_HEADER constant

* refactor: make time out value constant

* chore: revise the naming to IMDSv2

Co-authored-by: Bartlomiej Obecny <[email protected]>
Co-authored-by: Daniel Dyla <[email protected]>
  • Loading branch information
3 people authored Aug 18, 2020
1 parent 739462d commit 1f8c365
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ import { ResourceDetectionConfigWithLogger } from '../../../config';
class AwsEc2Detector implements Detector {
/**
* See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
* for documentation about the AWS instance identity document endpoint.
* for documentation about the AWS instance identity document
* and standard of IMDSv2.
*/
readonly AWS_INSTANCE_IDENTITY_DOCUMENT_URI =
'http://169.254.169.254/latest/dynamic/instance-identity/document';
readonly AWS_IDMS_ENDPOINT = '169.254.169.254';
readonly AWS_INSTANCE_TOKEN_DOCUMENT_PATH = '/latest/api/token';
readonly AWS_INSTANCE_IDENTITY_DOCUMENT_PATH =
'/latest/dynamic/instance-identity/document';
readonly AWS_INSTANCE_HOST_DOCUMENT_PATH = '/latest/meta-data/hostname';
readonly AWS_METADATA_TTL_HEADER = 'X-aws-ec2-metadata-token-ttl-seconds';
readonly AWS_METADATA_TOKEN_HEADER = 'X-aws-ec2-metadata-token';
readonly MILLISECOND_TIME_OUT = 1000;

/**
* Attempts to connect and obtain an AWS instance Identity document. If the
Expand All @@ -44,41 +51,86 @@ class AwsEc2Detector implements Detector {
*/
async detect(config: ResourceDetectionConfigWithLogger): Promise<Resource> {
try {
const token = await this._fetchToken();
const {
accountId,
instanceId,
instanceType,
region,
availabilityZone,
} = await this._awsMetadataAccessor();
} = await this._fetchIdentity(token);
const hostname = await this._fetchHost(token);

return new Resource({
[CLOUD_RESOURCE.PROVIDER]: 'aws',
[CLOUD_RESOURCE.ACCOUNT_ID]: accountId,
[CLOUD_RESOURCE.REGION]: region,
[CLOUD_RESOURCE.ZONE]: availabilityZone,
[HOST_RESOURCE.ID]: instanceId,
[HOST_RESOURCE.TYPE]: instanceType,
[HOST_RESOURCE.NAME]: hostname,
[HOST_RESOURCE.HOSTNAME]: hostname,
});
} catch (e) {
config.logger.debug(`AwsEc2Detector failed: ${e.message}`);
return Resource.empty();
}
}

private async _fetchToken(): Promise<string> {
const options = {
host: this.AWS_IDMS_ENDPOINT,
path: this.AWS_INSTANCE_TOKEN_DOCUMENT_PATH,
method: 'PUT',
timeout: this.MILLISECOND_TIME_OUT,
headers: {
[this.AWS_METADATA_TTL_HEADER]: '60',
},
};
return await this._fetchString(options);
}

private async _fetchIdentity(token: string): Promise<any> {
const options = {
host: this.AWS_IDMS_ENDPOINT,
path: this.AWS_INSTANCE_IDENTITY_DOCUMENT_PATH,
method: 'GET',
timeout: this.MILLISECOND_TIME_OUT,
headers: {
[this.AWS_METADATA_TOKEN_HEADER]: token,
},
};
const identity = await this._fetchString(options);
return JSON.parse(identity);
}

private async _fetchHost(token: string): Promise<string> {
const options = {
host: this.AWS_IDMS_ENDPOINT,
path: this.AWS_INSTANCE_HOST_DOCUMENT_PATH,
method: 'GET',
timeout: this.MILLISECOND_TIME_OUT,
headers: {
[this.AWS_METADATA_TOKEN_HEADER]: token,
},
};
return await this._fetchString(options);
}

/**
* Establishes an HTTP connection to AWS instance identity document url.
* Establishes an HTTP connection to AWS instance document url.
* If the application is running on an EC2 instance, we should be able
* to get back a valid JSON document. Parses that document and stores
* the identity properties in a local map.
*/
private async _awsMetadataAccessor<T>(): Promise<T> {
private async _fetchString(options: http.RequestOptions): Promise<string> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
req.abort();
reject(new Error('EC2 metadata api request timed out.'));
}, 1000);

const req = http.get(this.AWS_INSTANCE_IDENTITY_DOCUMENT_URI, res => {
const req = http.request(options, res => {
clearTimeout(timeoutId);
const { statusCode } = res;
res.setEncoding('utf8');
Expand All @@ -87,7 +139,7 @@ class AwsEc2Detector implements Detector {
res.on('end', () => {
if (statusCode && statusCode >= 200 && statusCode < 300) {
try {
resolve(JSON.parse(rawData));
resolve(rawData);
} catch (e) {
reject(e);
}
Expand All @@ -102,6 +154,7 @@ class AwsEc2Detector implements Detector {
clearTimeout(timeoutId);
reject(err);
});
req.end();
});
}
}
Expand Down
34 changes: 25 additions & 9 deletions packages/opentelemetry-resources/test/detect-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import * as nock from 'nock';
import * as sinon from 'sinon';
import * as assert from 'assert';
import { URL } from 'url';
import { Resource, detectResources } from '../src';
import { awsEc2Detector } from '../src/platform/node/detectors';
import {
Expand All @@ -43,17 +42,22 @@ const PROJECT_ID_PATH = BASE_PATH + '/project/project-id';
const ZONE_PATH = BASE_PATH + '/instance/zone';
const CLUSTER_NAME_PATH = BASE_PATH + '/instance/attributes/cluster-name';

const { origin: AWS_HOST, pathname: AWS_PATH } = new URL(
awsEc2Detector.AWS_INSTANCE_IDENTITY_DOCUMENT_URI
);
const AWS_HOST = 'http://' + awsEc2Detector.AWS_IDMS_ENDPOINT;
const AWS_TOKEN_PATH = awsEc2Detector.AWS_INSTANCE_TOKEN_DOCUMENT_PATH;
const AWS_IDENTITY_PATH = awsEc2Detector.AWS_INSTANCE_IDENTITY_DOCUMENT_PATH;
const AWS_HOST_PATH = awsEc2Detector.AWS_INSTANCE_HOST_DOCUMENT_PATH;
const AWS_METADATA_TTL_HEADER = awsEc2Detector.AWS_METADATA_TTL_HEADER;
const AWS_METADATA_TOKEN_HEADER = awsEc2Detector.AWS_METADATA_TOKEN_HEADER;

const mockedAwsResponse = {
const mockedTokenResponse = 'my-token';
const mockedIdentityResponse = {
instanceId: 'my-instance-id',
instanceType: 'my-instance-type',
accountId: 'my-account-id',
region: 'my-region',
availabilityZone: 'my-zone',
};
const mockedHostResponse = 'my-hostname';

describe('detectResources', async () => {
beforeEach(() => {
Expand Down Expand Up @@ -89,7 +93,9 @@ describe('detectResources', async () => {
.get(INSTANCE_PATH)
.reply(200, {}, HEADERS);
const awsScope = nock(AWS_HOST)
.get(AWS_PATH)
.persist()
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.replyWithError({ code: 'ENOTFOUND' });
const resource: Resource = await detectResources();
awsScope.done();
Expand Down Expand Up @@ -122,8 +128,16 @@ describe('detectResources', async () => {
code: 'ENOTFOUND',
});
const awsScope = nock(AWS_HOST)
.get(AWS_PATH)
.reply(200, () => mockedAwsResponse);
.persist()
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.reply(200, () => mockedTokenResponse)
.get(AWS_IDENTITY_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedIdentityResponse)
.get(AWS_HOST_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedHostResponse);
const resource: Resource = await detectResources();
gcpSecondaryScope.done();
gcpScope.done();
Expand All @@ -138,6 +152,8 @@ describe('detectResources', async () => {
assertHostResource(resource, {
id: 'my-instance-id',
hostType: 'my-instance-type',
name: 'my-hostname',
hostName: 'my-hostname',
});
assertServiceResource(resource, {
instanceId: '627cc493',
Expand Down Expand Up @@ -206,7 +222,7 @@ describe('detectResources', async () => {
assert.ok(
callArgsContains(
mockedLoggerMethod,
'AwsEc2Detector failed: Nock: Disallowed net connect for "169.254.169.254:80/latest/dynamic/instance-identity/document"'
'AwsEc2Detector failed: Nock: Disallowed net connect for "169.254.169.254:80/latest/api/token"'
)
);
// Test that the Env Detector successfully found its resource and populated it with the right values.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import * as nock from 'nock';
import * as assert from 'assert';
import { URL } from 'url';
import { Resource } from '../../src';
import { awsEc2Detector } from '../../src/platform/node/detectors/AwsEc2Detector';
import {
Expand All @@ -26,36 +25,51 @@ import {
} from '../util/resource-assertions';
import { NoopLogger } from '@opentelemetry/core';

const { origin: AWS_HOST, pathname: AWS_PATH } = new URL(
awsEc2Detector.AWS_INSTANCE_IDENTITY_DOCUMENT_URI
);
const AWS_HOST = 'http://' + awsEc2Detector.AWS_IDMS_ENDPOINT;
const AWS_TOKEN_PATH = awsEc2Detector.AWS_INSTANCE_TOKEN_DOCUMENT_PATH;
const AWS_IDENTITY_PATH = awsEc2Detector.AWS_INSTANCE_IDENTITY_DOCUMENT_PATH;
const AWS_HOST_PATH = awsEc2Detector.AWS_INSTANCE_HOST_DOCUMENT_PATH;
const AWS_METADATA_TTL_HEADER = awsEc2Detector.AWS_METADATA_TTL_HEADER;
const AWS_METADATA_TOKEN_HEADER = awsEc2Detector.AWS_METADATA_TOKEN_HEADER;

const mockedAwsResponse = {
const mockedTokenResponse = 'my-token';
const mockedIdentityResponse = {
instanceId: 'my-instance-id',
instanceType: 'my-instance-type',
accountId: 'my-account-id',
region: 'my-region',
availabilityZone: 'my-zone',
};
const mockedHostResponse = 'my-hostname';

describe('awsEc2Detector', () => {
before(() => {
beforeEach(() => {
nock.disableNetConnect();
nock.cleanAll();
});

after(() => {
afterEach(() => {
nock.enableNetConnect();
});

describe('with successful request', () => {
it('should return aws_ec2_instance resource', async () => {
const scope = nock(AWS_HOST)
.get(AWS_PATH)
.reply(200, () => mockedAwsResponse);
.persist()
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.reply(200, () => mockedTokenResponse)
.get(AWS_IDENTITY_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedIdentityResponse)
.get(AWS_HOST_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedHostResponse);

const resource: Resource = await awsEc2Detector.detect({
logger: new NoopLogger(),
});

scope.done();

assert.ok(resource);
Expand All @@ -68,18 +82,72 @@ describe('awsEc2Detector', () => {
assertHostResource(resource, {
id: 'my-instance-id',
hostType: 'my-instance-type',
name: 'my-hostname',
hostName: 'my-hostname',
});
});
});

describe('with failing request', () => {
it('should return empty resource', async () => {
const scope = nock(AWS_HOST).get(AWS_PATH).replyWithError({
code: 'ENOTFOUND',
describe('with unsuccessful request', () => {
it('should return empty resource when receiving error response code', async () => {
const scope = nock(AWS_HOST)
.persist()
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.reply(200, () => mockedTokenResponse)
.get(AWS_IDENTITY_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedIdentityResponse)
.get(AWS_HOST_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(404, () => new Error('NOT FOUND'));

const resource: Resource = await awsEc2Detector.detect({
logger: new NoopLogger(),
});

scope.done();

assert.ok(resource);
assertEmptyResource(resource);
});

it('should return empty resource when timeout', async () => {
const scope = nock(AWS_HOST)
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.reply(200, () => mockedTokenResponse)
.get(AWS_IDENTITY_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.reply(200, () => mockedIdentityResponse)
.get(AWS_HOST_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.delayConnection(2000)
.reply(200, () => mockedHostResponse);

const resource: Resource = await awsEc2Detector.detect({
logger: new NoopLogger(),
});

scope.done();

assert.ok(resource);
assertEmptyResource(resource);
});

it('should return empty resource when replied Error', async () => {
const scope = nock(AWS_HOST)
.put(AWS_TOKEN_PATH)
.matchHeader(AWS_METADATA_TTL_HEADER, '60')
.reply(200, () => mockedTokenResponse)
.get(AWS_IDENTITY_PATH)
.matchHeader(AWS_METADATA_TOKEN_HEADER, mockedTokenResponse)
.replyWithError('NOT FOUND');

const resource: Resource = await awsEc2Detector.detect({
logger: new NoopLogger(),
});

scope.done();

assert.ok(resource);
Expand Down

0 comments on commit 1f8c365

Please sign in to comment.