diff --git a/packages/opentelemetry-resource-detector-aws/src/detectors/AwsEcsDetector.ts b/packages/opentelemetry-resource-detector-aws/src/detectors/AwsEcsDetector.ts new file mode 100644 index 0000000000..5614f476dd --- /dev/null +++ b/packages/opentelemetry-resource-detector-aws/src/detectors/AwsEcsDetector.ts @@ -0,0 +1,87 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Detector, + Resource, + ResourceDetectionConfigWithLogger, + CONTAINER_RESOURCE, +} from '@opentelemetry/resources'; +import * as util from 'util'; +import * as fs from 'fs'; +import * as os from 'os'; + +/** + * The AwsEcsDetector can be used to detect if a process is running in AWS + * ECS and return a {@link Resource} populated with data about the ECS + * plugins of AWS X-Ray. Returns an empty Resource if detection fails. + */ +export class AwsEcsDetector implements Detector { + readonly CONTAINER_ID_LENGTH = 64; + readonly DEFAULT_CGROUP_PATH = '/proc/self/cgroup'; + private static readFileAsync = util.promisify(fs.readFile); + + async detect(config: ResourceDetectionConfigWithLogger): Promise { + if ( + !process.env.ECS_CONTAINER_METADATA_URI_V4 && + !process.env.ECS_CONTAINER_METADATA_URI + ) { + config.logger.debug('AwsEcsDetector failed: Process is not on ECS'); + return Resource.empty(); + } + + const hostName = os.hostname(); + const containerId = await this._getContainerId(config); + + return !hostName && !containerId + ? Resource.empty() + : new Resource({ + [CONTAINER_RESOURCE.NAME]: hostName || '', + [CONTAINER_RESOURCE.ID]: containerId || '', + }); + } + + /** + * Read container ID from cgroup file + * In ECS, even if we fail to find target file + * or target file does not contain container ID + * we do not throw an error but throw warning message + * and then return null string + */ + private async _getContainerId( + config: ResourceDetectionConfigWithLogger + ): Promise { + try { + const rawData = await AwsEcsDetector.readFileAsync( + this.DEFAULT_CGROUP_PATH, + 'utf8' + ); + const splitData = rawData.trim().split('\n'); + for (const str of splitData) { + if (str.length > this.CONTAINER_ID_LENGTH) { + return str.substring(str.length - this.CONTAINER_ID_LENGTH); + } + } + } catch (e) { + config.logger.warn( + `AwsEcsDetector failed to read container ID: ${e.message}` + ); + } + return undefined; + } +} + +export const awsEcsDetector = new AwsEcsDetector(); diff --git a/packages/opentelemetry-resource-detector-aws/src/detectors/index.ts b/packages/opentelemetry-resource-detector-aws/src/detectors/index.ts index 4bd440dd06..01986175c3 100644 --- a/packages/opentelemetry-resource-detector-aws/src/detectors/index.ts +++ b/packages/opentelemetry-resource-detector-aws/src/detectors/index.ts @@ -16,3 +16,4 @@ export * from './AwsEc2Detector'; export * from './AwsBeanstalkDetector'; +export * from './AwsEcsDetector'; diff --git a/packages/opentelemetry-resource-detector-aws/test/detectors/AwsEcsDetector.test.ts b/packages/opentelemetry-resource-detector-aws/test/detectors/AwsEcsDetector.test.ts new file mode 100644 index 0000000000..e8bed2b3e5 --- /dev/null +++ b/packages/opentelemetry-resource-detector-aws/test/detectors/AwsEcsDetector.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { + awsEcsDetector, + AwsEcsDetector, +} from '../../src/detectors/AwsEcsDetector'; +import { + assertEmptyResource, + assertContainerResource, +} from '@opentelemetry/resources/test/util/resource-assertions'; +import { NoopLogger } from '@opentelemetry/core'; +import * as os from 'os'; + +describe('BeanstalkResourceDetector', () => { + const errorMsg = { + fileNotFoundError: new Error('cannot find cgroup file'), + }; + + const correctCgroupData = + 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm'; + const unexpectedCgroupdata = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const noisyCgroupData = `\n\n\n abcdefghijklmnopqrstuvwxyz \n ${correctCgroupData}`; + const multiValidCgroupData = `${unexpectedCgroupdata}\n${correctCgroupData}\nbcd${unexpectedCgroupdata}`; + const hostNameData = 'abcd.test.testing.com'; + + let readStub, hostStub; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + process.env.ECS_CONTAINER_METADATA_URI_V4 = ''; + process.env.ECS_CONTAINER_METADATA_URI = ''; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should successfully return resource data', async () => { + process.env.ECS_CONTAINER_METADATA_URI_V4 = 'ecs_metadata_v4_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(correctCgroupData); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + name: 'abcd.test.testing.com', + id: 'bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm', + }); + }); + + it('should successfully return resource data with noisy cgroup file', async () => { + process.env.ECS_CONTAINER_METADATA_URI = 'ecs_metadata_v3_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(noisyCgroupData); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + name: 'abcd.test.testing.com', + id: 'bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm', + }); + }); + + it('should always return first valid line of data', async () => { + process.env.ECS_CONTAINER_METADATA_URI = 'ecs_metadata_v3_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(multiValidCgroupData); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + name: 'abcd.test.testing.com', + id: 'bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm', + }); + }); + + it('should empty resource without environmental variable', async () => { + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(correctCgroupData); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.notCalled(hostStub); + sandbox.assert.notCalled(readStub); + assert.ok(resource); + assertEmptyResource(resource); + }); + + it('should return resource only with hostname attribute without cgroup file', async () => { + process.env.ECS_CONTAINER_METADATA_URI_V4 = 'ecs_metadata_v4_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .rejects(errorMsg.fileNotFoundError); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + name: 'abcd.test.testing.com', + }); + }); + + it('should return resource only with hostname attribute when cgroup file does not contain valid container ID', async () => { + process.env.ECS_CONTAINER_METADATA_URI_V4 = 'ecs_metadata_v4_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(hostNameData); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(''); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + name: 'abcd.test.testing.com', + }); + }); + + it('should return resource only with container ID attribute without hostname', async () => { + process.env.ECS_CONTAINER_METADATA_URI_V4 = 'ecs_metadata_v4_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(''); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .resolves(correctCgroupData); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertContainerResource(resource, { + id: 'bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm', + }); + }); + + it('should return empty resource when both hostname and container ID are invalid', async () => { + process.env.ECS_CONTAINER_METADATA_URI_V4 = 'ecs_metadata_v4_uri'; + hostStub = sandbox.stub(os, 'hostname').returns(''); + readStub = sandbox + .stub(AwsEcsDetector, 'readFileAsync' as any) + .rejects(errorMsg.fileNotFoundError); + + const resource = await awsEcsDetector.detect({ + logger: new NoopLogger(), + }); + + sandbox.assert.calledOnce(hostStub); + sandbox.assert.calledOnce(readStub); + assert.ok(resource); + assertEmptyResource(resource); + }); +}); diff --git a/packages/opentelemetry-resources/src/constants.ts b/packages/opentelemetry-resources/src/constants.ts index 2169f82b5b..298241a1df 100644 --- a/packages/opentelemetry-resources/src/constants.ts +++ b/packages/opentelemetry-resources/src/constants.ts @@ -35,6 +35,9 @@ export const CONTAINER_RESOURCE = { /** The container name. */ NAME: 'container.name', + /** The container id. */ + ID: 'container.id', + /** The name of the image the container was built on. */ IMAGE_NAME: 'container.image.name', diff --git a/packages/opentelemetry-resources/test/resource-assertions.test.ts b/packages/opentelemetry-resources/test/resource-assertions.test.ts index c3a99f5998..01952f3839 100644 --- a/packages/opentelemetry-resources/test/resource-assertions.test.ts +++ b/packages/opentelemetry-resources/test/resource-assertions.test.ts @@ -66,11 +66,13 @@ describe('assertContainerResource', () => { it('validates optional attributes', () => { const resource = new Resource({ [CONTAINER_RESOURCE.NAME]: 'opentelemetry-autoconf', + [CONTAINER_RESOURCE.ID]: 'abc', [CONTAINER_RESOURCE.IMAGE_NAME]: 'gcr.io/opentelemetry/operator', [CONTAINER_RESOURCE.IMAGE_TAG]: '0.1', }); assertContainerResource(resource, { name: 'opentelemetry-autoconf', + id: 'abc', imageName: 'gcr.io/opentelemetry/operator', imageTag: '0.1', }); diff --git a/packages/opentelemetry-resources/test/util/resource-assertions.ts b/packages/opentelemetry-resources/test/util/resource-assertions.ts index c39b86a579..4212058a03 100644 --- a/packages/opentelemetry-resources/test/util/resource-assertions.ts +++ b/packages/opentelemetry-resources/test/util/resource-assertions.ts @@ -75,6 +75,7 @@ export const assertContainerResource = ( resource: Resource, validations: { name?: string; + id?: string; imageName?: string; imageTag?: string; } @@ -85,6 +86,11 @@ export const assertContainerResource = ( resource.attributes[CONTAINER_RESOURCE.NAME], validations.name ); + if (validations.id) + assert.strictEqual( + resource.attributes[CONTAINER_RESOURCE.ID], + validations.id + ); if (validations.imageName) assert.strictEqual( resource.attributes[CONTAINER_RESOURCE.IMAGE_NAME],