Skip to content

Commit f3db796

Browse files
committed
fix(detector-aws): extract full container ID from ECS Fargate cgroup paths
1 parent de22600 commit f3db796

File tree

2 files changed

+358
-4
lines changed

2 files changed

+358
-4
lines changed

packages/resource-detector-aws/src/detectors/AwsEcsDetector.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,8 @@ export class AwsEcsDetector implements ResourceDetector {
163163
);
164164
const splitData = rawData.trim().split('\n');
165165
for (const str of splitData) {
166-
if (str.length > AwsEcsDetector.CONTAINER_ID_LENGTH) {
167-
containerId = str.substring(
168-
str.length - AwsEcsDetector.CONTAINER_ID_LENGTH
169-
);
166+
containerId = this._extractContainerIdFromLine(str);
167+
if (containerId) {
170168
break;
171169
}
172170
}
@@ -176,6 +174,74 @@ export class AwsEcsDetector implements ResourceDetector {
176174
return containerId;
177175
}
178176

177+
/**
178+
* Extract container ID from a cgroup line.
179+
* Handles the new AWS ECS Fargate format: /ecs/<taskId>/<taskId>-<containerId>
180+
* Returns the last segment after the final '/' which should be the complete container ID.
181+
*/
182+
private _extractContainerIdFromLine(line: string): string | undefined {
183+
if (!line) {
184+
return undefined;
185+
}
186+
187+
// Split by '/' and get the last segment
188+
const segments = line.split('/');
189+
if (segments.length <= 1) {
190+
// Fallback to original logic if no '/' found
191+
if (line.length > AwsEcsDetector.CONTAINER_ID_LENGTH) {
192+
return line.substring(line.length - AwsEcsDetector.CONTAINER_ID_LENGTH);
193+
}
194+
return undefined;
195+
}
196+
197+
let lastSegment = segments[segments.length - 1];
198+
199+
// Handle containerd v1.5.0+ format with systemd cgroup driver (e.g., ending with :cri-containerd:containerid)
200+
const colonIndex = lastSegment.lastIndexOf(':');
201+
if (colonIndex !== -1) {
202+
lastSegment = lastSegment.substring(colonIndex + 1);
203+
}
204+
205+
// Remove known prefixes if they exist
206+
const prefixes = ['docker-', 'crio-', 'cri-containerd-'];
207+
for (const prefix of prefixes) {
208+
if (lastSegment.startsWith(prefix)) {
209+
lastSegment = lastSegment.substring(prefix.length);
210+
break;
211+
}
212+
}
213+
214+
// Remove anything after the first period (like .scope)
215+
if (lastSegment.includes('.')) {
216+
lastSegment = lastSegment.split('.')[0];
217+
}
218+
219+
// Basic validation: should not be empty and should have reasonable length
220+
if (!lastSegment || lastSegment.length < 8) {
221+
return undefined;
222+
}
223+
224+
// AWS ECS container IDs can be in various formats:
225+
// 1. Pure hex strings: 'abcdef123456'
226+
// 2. ECS format: 'taskId-containerId'
227+
// 3. Mixed alphanumeric with hyphens
228+
// We'll be more permissive and allow alphanumeric characters and hyphens
229+
const containerIdPattern = /^[a-zA-Z0-9\-_]+$/;
230+
231+
if (containerIdPattern.test(lastSegment)) {
232+
return lastSegment;
233+
}
234+
235+
// If the pattern doesn't match but the segment looks reasonable,
236+
// still try to return it (last resort for edge cases)
237+
if (lastSegment.length >= 12 && lastSegment.length <= 128) {
238+
diag.debug(`AwsEcsDetector: Using container ID with non-standard format: ${lastSegment}`);
239+
return lastSegment;
240+
}
241+
242+
return undefined;
243+
}
244+
179245
/**
180246
* Add metadata-v4-related resource attributes to `data` (in-place)
181247
*/

packages/resource-detector-aws/test/detectors/AwsEcsDetector.test.ts

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,291 @@ describe('AwsEcsResourceDetector', () => {
453453
});
454454
});
455455
});
456+
457+
describe('AwsEcsDetector - Container ID extraction improvements', () => {
458+
let readStub: sinon.SinonStub;
459+
460+
beforeEach(() => {
461+
process.env.ECS_CONTAINER_METADATA_URI_V4 = 'http://169.254.170.2/v4/test';
462+
});
463+
464+
afterEach(() => {
465+
sinon.restore();
466+
});
467+
468+
describe('New AWS ECS Fargate cgroup format support', () => {
469+
it('should extract full container ID from new AWS ECS Fargate format', async () => {
470+
const taskId = 'c23e5f76c09d438aa1824ca4058bdcab';
471+
const containerId = '1234567890abcdef';
472+
const cgroupData = `/ecs/${taskId}/${taskId}-${containerId}`;
473+
474+
sinon.stub(os, 'hostname').returns('test-hostname');
475+
readStub = sinon
476+
.stub(AwsEcsDetector, 'readFileAsync' as any)
477+
.resolves(cgroupData);
478+
479+
// Mock the metadata requests
480+
const nockScope = nock('http://169.254.170.2:80')
481+
.persist(false)
482+
.get('/v4/test')
483+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
484+
.get('/v4/test/task')
485+
.reply(200, {
486+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
487+
Family: 'test-family',
488+
Revision: '1',
489+
Cluster: 'test-cluster',
490+
LaunchType: 'FARGATE'
491+
});
492+
493+
const resource = detectResources({ detectors: [awsEcsDetector] });
494+
await resource.waitForAsyncAttributes?.();
495+
496+
sinon.assert.calledOnce(readStub);
497+
assert.ok(resource);
498+
assertEcsResource(resource, {});
499+
assertContainerResource(resource, {
500+
name: 'test-hostname',
501+
id: `${taskId}-${containerId}`, // Expected: full taskId-containerId, not truncated
502+
});
503+
504+
nockScope.done();
505+
});
506+
507+
it('should extract container ID from long cgroup path without truncation', async () => {
508+
// Simulate the actual issue where the path is longer than 64 chars
509+
const longTaskId = 'abcdefgh12345678abcdefgh12345678abcdefgh12345678';
510+
const containerId = '1234567890abcdef';
511+
const cgroupData = `/ecs/${longTaskId}/${longTaskId}-${containerId}`;
512+
513+
sinon.stub(os, 'hostname').returns('test-hostname');
514+
readStub = sinon
515+
.stub(AwsEcsDetector, 'readFileAsync' as any)
516+
.resolves(cgroupData);
517+
518+
// Mock the metadata requests
519+
const nockScope = nock('http://169.254.170.2:80')
520+
.persist(false)
521+
.get('/v4/test')
522+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
523+
.get('/v4/test/task')
524+
.reply(200, {
525+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
526+
Family: 'test-family',
527+
Revision: '1',
528+
Cluster: 'test-cluster',
529+
LaunchType: 'FARGATE'
530+
});
531+
532+
const resource = detectResources({ detectors: [awsEcsDetector] });
533+
await resource.waitForAsyncAttributes?.();
534+
535+
sinon.assert.calledOnce(readStub);
536+
assert.ok(resource);
537+
assertEcsResource(resource, {});
538+
assertContainerResource(resource, {
539+
name: 'test-hostname',
540+
id: `${longTaskId}-${containerId}`, // Should get full ID, not truncated
541+
});
542+
543+
nockScope.done();
544+
});
545+
546+
it('should handle multiple cgroup lines and pick the valid one', async () => {
547+
const taskId = 'c23e5f76c09d438aa1824ca4058bdcab';
548+
const containerId = '1234567890abcdef';
549+
const cgroupData = [
550+
'12:memory:/ecs',
551+
'11:cpu:/ecs/task-id',
552+
`10:devices:/ecs/${taskId}/${taskId}-${containerId}`,
553+
'9:freezer:/ecs'
554+
].join('\n');
555+
556+
sinon.stub(os, 'hostname').returns('test-hostname');
557+
readStub = sinon
558+
.stub(AwsEcsDetector, 'readFileAsync' as any)
559+
.resolves(cgroupData);
560+
561+
// Mock the metadata requests
562+
const nockScope = nock('http://169.254.170.2:80')
563+
.persist(false)
564+
.get('/v4/test')
565+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
566+
.get('/v4/test/task')
567+
.reply(200, {
568+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
569+
Family: 'test-family',
570+
Revision: '1',
571+
Cluster: 'test-cluster',
572+
LaunchType: 'FARGATE'
573+
});
574+
575+
const resource = detectResources({ detectors: [awsEcsDetector] });
576+
await resource.waitForAsyncAttributes?.();
577+
578+
sinon.assert.calledOnce(readStub);
579+
assert.ok(resource);
580+
assertEcsResource(resource, {});
581+
assertContainerResource(resource, {
582+
name: 'test-hostname',
583+
id: `${taskId}-${containerId}`,
584+
});
585+
586+
nockScope.done();
587+
});
588+
});
589+
590+
describe('Edge cases and format variations', () => {
591+
it('should handle containerd format with colon separators', async () => {
592+
const taskId = 'c23e5f76c09d438aa1824ca4058bdcab';
593+
const containerId = '1234567890abcdef';
594+
const cgroupData = `0::/system.slice/containerd.service/kubepods-burstable-pod.slice:cri-containerd:${taskId}-${containerId}`;
595+
596+
sinon.stub(os, 'hostname').returns('test-hostname');
597+
readStub = sinon
598+
.stub(AwsEcsDetector, 'readFileAsync' as any)
599+
.resolves(cgroupData);
600+
601+
// Mock the metadata requests
602+
const nockScope = nock('http://169.254.170.2:80')
603+
.persist(false)
604+
.get('/v4/test')
605+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
606+
.get('/v4/test/task')
607+
.reply(200, {
608+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
609+
Family: 'test-family',
610+
Revision: '1',
611+
Cluster: 'test-cluster',
612+
LaunchType: 'FARGATE'
613+
});
614+
615+
const resource = detectResources({ detectors: [awsEcsDetector] });
616+
await resource.waitForAsyncAttributes?.();
617+
618+
sinon.assert.calledOnce(readStub);
619+
assert.ok(resource);
620+
assertEcsResource(resource, {});
621+
assertContainerResource(resource, {
622+
name: 'test-hostname',
623+
id: `${taskId}-${containerId}`,
624+
});
625+
626+
nockScope.done();
627+
});
628+
629+
it('should handle docker prefix and scope suffix', async () => {
630+
const taskId = 'c23e5f76c09d438aa1824ca4058bdcab';
631+
const containerId = '1234567890abcdef';
632+
const cgroupData = `/docker/docker-${taskId}-${containerId}.scope`;
633+
634+
sinon.stub(os, 'hostname').returns('test-hostname');
635+
readStub = sinon
636+
.stub(AwsEcsDetector, 'readFileAsync' as any)
637+
.resolves(cgroupData);
638+
639+
// Mock the metadata requests
640+
const nockScope = nock('http://169.254.170.2:80')
641+
.persist(false)
642+
.get('/v4/test')
643+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
644+
.get('/v4/test/task')
645+
.reply(200, {
646+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
647+
Family: 'test-family',
648+
Revision: '1',
649+
Cluster: 'test-cluster',
650+
LaunchType: 'FARGATE'
651+
});
652+
653+
const resource = detectResources({ detectors: [awsEcsDetector] });
654+
await resource.waitForAsyncAttributes?.();
655+
656+
sinon.assert.calledOnce(readStub);
657+
assert.ok(resource);
658+
assertEcsResource(resource, {});
659+
assertContainerResource(resource, {
660+
name: 'test-hostname',
661+
id: `${taskId}-${containerId}`,
662+
});
663+
664+
nockScope.done();
665+
});
666+
667+
it('should return undefined for invalid container ID formats', async () => {
668+
const invalidCgroupData = '/invalid/path/with/non-hex-characters!!!';
669+
670+
sinon.stub(os, 'hostname').returns('test-hostname');
671+
readStub = sinon
672+
.stub(AwsEcsDetector, 'readFileAsync' as any)
673+
.resolves(invalidCgroupData);
674+
675+
// Mock the metadata requests
676+
const nockScope = nock('http://169.254.170.2:80')
677+
.persist(false)
678+
.get('/v4/test')
679+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
680+
.get('/v4/test/task')
681+
.reply(200, {
682+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
683+
Family: 'test-family',
684+
Revision: '1',
685+
Cluster: 'test-cluster',
686+
LaunchType: 'FARGATE'
687+
});
688+
689+
const resource = detectResources({ detectors: [awsEcsDetector] });
690+
await resource.waitForAsyncAttributes?.();
691+
692+
sinon.assert.calledOnce(readStub);
693+
assert.ok(resource);
694+
assertEcsResource(resource, {});
695+
assertContainerResource(resource, {
696+
name: 'test-hostname',
697+
// id should be undefined due to invalid format
698+
});
699+
700+
nockScope.done();
701+
});
702+
});
703+
704+
describe('Backward compatibility', () => {
705+
it('should fallback to original logic for legacy format', async () => {
706+
// Test backward compatibility with existing 64-char format
707+
const legacyContainerId = 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm';
708+
709+
sinon.stub(os, 'hostname').returns('test-hostname');
710+
readStub = sinon
711+
.stub(AwsEcsDetector, 'readFileAsync' as any)
712+
.resolves(legacyContainerId);
713+
714+
// Mock the metadata requests
715+
const nockScope = nock('http://169.254.170.2:80')
716+
.persist(false)
717+
.get('/v4/test')
718+
.reply(200, { ContainerARN: 'arn:aws:ecs:us-west-2:111122223333:container/test' })
719+
.get('/v4/test/task')
720+
.reply(200, {
721+
TaskARN: 'arn:aws:ecs:us-west-2:111122223333:task/default/test',
722+
Family: 'test-family',
723+
Revision: '1',
724+
Cluster: 'test-cluster',
725+
LaunchType: 'FARGATE'
726+
});
727+
728+
const resource = detectResources({ detectors: [awsEcsDetector] });
729+
await resource.waitForAsyncAttributes?.();
730+
731+
sinon.assert.calledOnce(readStub);
732+
assert.ok(resource);
733+
assertEcsResource(resource, {});
734+
assertContainerResource(resource, {
735+
name: 'test-hostname',
736+
id: 'bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm', // Last 64 chars
737+
});
738+
739+
nockScope.done();
740+
});
741+
});
742+
});
743+

0 commit comments

Comments
 (0)