diff --git a/packages/opentelemetry-resources/src/platform/node/detectors/AwsEc2Detector.ts b/packages/opentelemetry-resources/src/platform/node/detectors/AwsEc2Detector.ts index fd248b822f..3b7cf720d9 100644 --- a/packages/opentelemetry-resources/src/platform/node/detectors/AwsEc2Detector.ts +++ b/packages/opentelemetry-resources/src/platform/node/detectors/AwsEc2Detector.ts @@ -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 @@ -44,13 +51,16 @@ class AwsEc2Detector implements Detector { */ async detect(config: ResourceDetectionConfigWithLogger): Promise { 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, @@ -58,6 +68,8 @@ class AwsEc2Detector implements Detector { [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}`); @@ -65,20 +77,60 @@ class AwsEc2Detector implements Detector { } } + private async _fetchToken(): Promise { + 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 { + 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 { + 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(): Promise { + private async _fetchString(options: http.RequestOptions): Promise { 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'); @@ -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); } @@ -102,6 +154,7 @@ class AwsEc2Detector implements Detector { clearTimeout(timeoutId); reject(err); }); + req.end(); }); } } diff --git a/packages/opentelemetry-resources/test/detect-resources.test.ts b/packages/opentelemetry-resources/test/detect-resources.test.ts index aed604b481..ee6e091702 100644 --- a/packages/opentelemetry-resources/test/detect-resources.test.ts +++ b/packages/opentelemetry-resources/test/detect-resources.test.ts @@ -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 { @@ -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(() => { @@ -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(); @@ -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(); @@ -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', @@ -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. diff --git a/packages/opentelemetry-resources/test/detectors/AwsEc2Detector.test.ts b/packages/opentelemetry-resources/test/detectors/AwsEc2Detector.test.ts index 9b9e6eb035..910524d868 100644 --- a/packages/opentelemetry-resources/test/detectors/AwsEc2Detector.test.ts +++ b/packages/opentelemetry-resources/test/detectors/AwsEc2Detector.test.ts @@ -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 { @@ -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); @@ -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);