Skip to content

Commit 01bff2b

Browse files
authored
Merge pull request #1209 from newrelic/ecs-docker-id
Add support for ECS container IDs.
2 parents c07d6f4 + 0a475af commit 01bff2b

File tree

6 files changed

+270
-27
lines changed

6 files changed

+270
-27
lines changed

newrelic/common/utilization.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
import os
1717
import re
1818
import socket
19+
import json
1920
import string
21+
import newrelic.packages.urllib3 as urllib3
2022

2123
from newrelic.common.agent_http import InsecureHttpClient
2224
from newrelic.common.encoding_utils import json_decode
2325
from newrelic.core.internal_metrics import internal_count_metric
2426

27+
2528
_logger = logging.getLogger(__name__)
2629
VALID_CHARS_RE = re.compile(r"[0-9a-zA-Z_ ./-]")
2730

@@ -309,7 +312,6 @@ def get_values(cls, contents):
309312
def valid_chars(cls, data):
310313
if data is None:
311314
return False
312-
313315
hex_digits = set(string.hexdigits)
314316

315317
valid = all((c in hex_digits for c in data))
@@ -327,6 +329,57 @@ def valid_length(cls, data):
327329
return bool(len(data) == 64)
328330

329331

332+
class ECSUtilization(CommonUtilization):
333+
VENDOR_NAME = "ecs"
334+
EXPECTED_KEYS = ("ecsDockerId",)
335+
336+
@classmethod
337+
def fetch(cls):
338+
# First, try checking ECS V4 metadata env var
339+
try:
340+
uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
341+
if uri:
342+
ecs_id = cls.get_ecs_container_id(uri)
343+
if ecs_id:
344+
return ecs_id
345+
except:
346+
# There are all sorts of exceptions that can occur here
347+
# (i.e. permissions, non-existent file, etc)
348+
pass
349+
350+
# If V4 didn't work, try the older version
351+
try:
352+
uri = os.environ.get("ECS_CONTAINER_METADATA_URI")
353+
if uri:
354+
ecs_id = cls.get_ecs_container_id(uri)
355+
if ecs_id:
356+
return ecs_id
357+
except:
358+
# There are all sorts of exceptions that can occur here
359+
# (i.e. permissions, non-existent file, etc)
360+
pass
361+
362+
@classmethod
363+
def get_ecs_container_id(cls, metadata_uri):
364+
try:
365+
http = urllib3.PoolManager()
366+
resp = http.request("GET", metadata_uri)
367+
resp_dict = json.loads(resp.data)
368+
docker_id = resp_dict.get("DockerId")
369+
resp.release_conn()
370+
return docker_id
371+
except:
372+
_logger.debug("Unable to fetch Docker container ID data from ECS endpoint: %s", metadata_uri)
373+
return None
374+
375+
@classmethod
376+
def get_values(cls, contents):
377+
if contents is None:
378+
return
379+
380+
return {"ecsDockerId": contents}
381+
382+
330383
class KubernetesUtilization(CommonUtilization):
331384
EXPECTED_KEYS = ("kubernetes_service_host",)
332385
VENDOR_NAME = "kubernetes"

newrelic/core/agent_protocol.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
AWSUtilization,
2828
AzureUtilization,
2929
DockerUtilization,
30+
ECSUtilization,
3031
GCPUtilization,
3132
KubernetesUtilization,
3233
PCFUtilization,
@@ -321,26 +322,33 @@ def _connect_payload(app_name, linked_applications, environment, settings):
321322
utilization_settings["config"] = utilization_conf
322323

323324
vendors = []
325+
ecs_id = None
326+
utilization_vendor_settings = {}
327+
324328
if settings["utilization.detect_aws"]:
325329
vendors.append(AWSUtilization)
330+
ecs_id = ECSUtilization.detect()
331+
if ecs_id:
332+
utilization_vendor_settings["ecs"] = ecs_id
333+
326334
if settings["utilization.detect_pcf"]:
327335
vendors.append(PCFUtilization)
328336
if settings["utilization.detect_gcp"]:
329337
vendors.append(GCPUtilization)
330338
if settings["utilization.detect_azure"]:
331339
vendors.append(AzureUtilization)
332340

333-
utilization_vendor_settings = {}
334341
for vendor in vendors:
335342
metadata = vendor.detect()
336343
if metadata:
337344
utilization_vendor_settings[vendor.VENDOR_NAME] = metadata
338345
break
339346

340347
if settings["utilization.detect_docker"]:
341-
docker = DockerUtilization.detect()
342-
if docker:
343-
utilization_vendor_settings["docker"] = docker
348+
if not ecs_id:
349+
docker = DockerUtilization.detect()
350+
if docker:
351+
utilization_vendor_settings["docker"] = docker
344352

345353
if settings["utilization.detect_kubernetes"]:
346354
kubernetes = KubernetesUtilization.detect()

tests/agent_unittests/test_agent_protocol.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
# Global constants used in tests
4343
APP_NAME = "test_app"
44-
IP_ADDRESS = AWS = AZURE = GCP = PCF = BOOT_ID = DOCKER = KUBERNETES = None
44+
IP_ADDRESS = AWS = AZURE = ECS = GCP = PCF = BOOT_ID = DOCKER = KUBERNETES = None
4545
BROWSER_MONITORING_DEBUG = "debug"
4646
BROWSER_MONITORING_LOADER = "loader"
4747
CAPTURE_PARAMS = "capture_params"
@@ -116,9 +116,10 @@ def clear_sent_values():
116116

117117
@pytest.fixture(autouse=True)
118118
def override_utilization(monkeypatch):
119-
global AWS, AZURE, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES
119+
global AWS, AZURE, ECS, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES
120120
AWS = {"id": "foo", "type": "bar", "zone": "baz"}
121121
AZURE = {"location": "foo", "name": "bar", "vmId": "baz", "vmSize": "boo"}
122+
ECS = {"ecsDockerId": "foobar"}
122123
GCP = {"id": 1, "machineType": "trmntr-t1000", "name": "arnold", "zone": "abc"}
123124
PCF = {"cf_instance_guid": "1", "cf_instance_ip": "7", "memory_limit": "0"}
124125
BOOT_ID = "cca356a7d72737f645a10c122ebbe906"
@@ -133,8 +134,12 @@ def detect(cls):
133134
output = BOOT_ID
134135
elif name.startswith("AWS"):
135136
output = AWS
137+
elif name.startswith("ECS"):
138+
output = ECS
136139
elif name.startswith("Azure"):
137140
output = AZURE
141+
elif name.startswith("ECS"):
142+
output = ECS
138143
elif name.startswith("GCP"):
139144
output = GCP
140145
elif name.startswith("PCF"):
@@ -289,6 +294,7 @@ def test_close_connection():
289294
def connect_payload_asserts(
290295
payload,
291296
with_aws=True,
297+
with_ecs=True,
292298
with_gcp=True,
293299
with_pcf=True,
294300
with_azure=True,
@@ -326,7 +332,7 @@ def connect_payload_asserts(
326332
else:
327333
assert "ip_address" not in payload_data["utilization"]
328334

329-
utilization_len = utilization_len + any([with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes])
335+
utilization_len = utilization_len + any([with_aws, with_ecs, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes])
330336
assert len(payload_data["utilization"]) == utilization_len
331337
assert payload_data["utilization"]["hostname"] == HOST
332338

@@ -343,11 +349,13 @@ def connect_payload_asserts(
343349
assert harvest_limits["error_event_data"] == ERROR_EVENT_DATA
344350

345351
vendors_len = 0
346-
347352
if any([with_aws, with_pcf, with_gcp, with_azure]):
348353
vendors_len += 1
349354

350-
if with_docker:
355+
if with_ecs:
356+
vendors_len += 1
357+
358+
if with_docker and not with_ecs:
351359
vendors_len += 1
352360

353361
if with_kubernetes:
@@ -366,7 +374,10 @@ def connect_payload_asserts(
366374
elif with_azure:
367375
assert payload_data["utilization"]["vendors"]["azure"] == AZURE
368376

369-
if with_docker:
377+
if with_ecs:
378+
assert payload_data["utilization"]["vendors"]["ecs"] == ECS
379+
380+
if with_docker and not with_ecs:
370381
assert payload_data["utilization"]["vendors"]["docker"] == DOCKER
371382

372383
if with_kubernetes:
@@ -376,24 +387,25 @@ def connect_payload_asserts(
376387

377388

378389
@pytest.mark.parametrize(
379-
"with_aws,with_pcf,with_gcp,with_azure,with_docker,with_kubernetes,with_ip",
390+
"with_aws,with_ecs,with_pcf,with_gcp,with_azure,with_docker,with_kubernetes,with_ip",
380391
[
381-
(False, False, False, False, False, False, False),
382-
(False, False, False, False, False, False, True),
383-
(True, False, False, False, True, True, True),
384-
(False, True, False, False, True, True, True),
385-
(False, False, True, False, True, True, True),
386-
(False, False, False, True, True, True, True),
387-
(True, False, False, False, False, False, True),
388-
(False, True, False, False, False, False, True),
389-
(False, False, True, False, False, False, True),
390-
(False, False, False, True, False, False, True),
391-
(True, True, True, True, True, True, True),
392-
(True, True, True, True, True, False, True),
393-
(True, True, True, True, False, True, True),
392+
(False, False, False, False, False, False, False, False),
393+
(False, False, False, False, False, False, False, True),
394+
(True, True, False, False, False, True, False, True),
395+
(True, True, False, False, False, True, True, True),
396+
(False, False, True, False, False, True, True, True),
397+
(False, False, False, True, False, True, True, True),
398+
(False, False, False, False, True, True, True, True),
399+
(True, True, False, False, False, False, False, True),
400+
(False, False, True, False, False, False, False, True),
401+
(False, False, False, True, False, False, False, True),
402+
(False, False, False, False, True, False, False, True),
403+
(True, True, True, True, True, True, True, True),
404+
(True, True, True, True, True, True, False, True),
405+
(True, True, True, True, True, False, True, True),
394406
],
395407
)
396-
def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes, with_ip):
408+
def test_connect(with_aws, with_ecs, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes, with_ip):
397409
global AWS, AZURE, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES, IP_ADDRESS
398410
if not with_aws:
399411
AWS = Exception
@@ -403,6 +415,8 @@ def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kub
403415
GCP = Exception
404416
if not with_azure:
405417
AZURE = Exception
418+
if not with_ecs:
419+
ECS = Exception
406420
if not with_docker:
407421
DOCKER = Exception
408422
if not with_kubernetes:
@@ -460,6 +474,7 @@ def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kub
460474
with_pcf=with_pcf,
461475
with_gcp=with_gcp,
462476
with_azure=with_azure,
477+
with_ecs=with_ecs,
463478
with_docker=with_docker,
464479
with_kubernetes=with_kubernetes,
465480
)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import pytest
2+
import json
3+
from testing_support.mock_external_http_server import MockExternalHTTPServer
4+
5+
STANDARD_RESPONSE = {
6+
"DockerId": "1e1698469422439ea356071e581e8545-2769485393",
7+
"Name": "fargateapp",
8+
"DockerName": "fargateapp",
9+
"Image": "123456789012.dkr.ecr.us-west-2.amazonaws.com/fargatetest:latest",
10+
"ImageID": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd",
11+
"Labels": {
12+
"com.amazonaws.ecs.cluster": "arn:aws:ecs:us-west-2:123456789012:cluster/testcluster",
13+
"com.amazonaws.ecs.container-name": "fargateapp",
14+
"com.amazonaws.ecs.task-arn": "arn:aws:ecs:us-west-2:123456789012:task/testcluster/1e1698469422439ea356071e581e8545",
15+
"com.amazonaws.ecs.task-definition-family": "fargatetestapp",
16+
"com.amazonaws.ecs.task-definition-version": "7",
17+
},
18+
"DesiredStatus": "RUNNING",
19+
"KnownStatus": "RUNNING",
20+
"Limits": {"CPU": 2},
21+
"CreatedAt": "2024-04-25T17:38:31.073208914Z",
22+
"StartedAt": "2024-04-25T17:38:31.073208914Z",
23+
"Type": "NORMAL",
24+
"LogDriver": "awslogs",
25+
"LogOptions": {
26+
"awslogs-create-group": "true",
27+
"awslogs-group": "/ecs/fargatetestapp",
28+
"awslogs-region": "us-west-2",
29+
"awslogs-stream": "ecs/fargateapp/1e1698469422439ea356071e581e8545",
30+
},
31+
"ContainerARN": "arn:aws:ecs:us-west-2:123456789012:container/testcluster/1e1698469422439ea356071e581e8545/050256a5-a7f3-461c-a16f-aca4eae37b01",
32+
"Networks": [
33+
{
34+
"NetworkMode": "awsvpc",
35+
"IPv4Addresses": ["10.10.10.10"],
36+
"AttachmentIndex": 0,
37+
"MACAddress": "06:d7:3f:49:1d:a7",
38+
"IPv4SubnetCIDRBlock": "10.10.10.0/20",
39+
"DomainNameServers": ["10.10.10.2"],
40+
"DomainNameSearchList": ["us-west-2.compute.internal"],
41+
"PrivateDNSName": "ip-10-10-10-10.us-west-2.compute.internal",
42+
"SubnetGatewayIpv4Address": "10.10.10.1/20",
43+
}
44+
],
45+
"Snapshotter": "overlayfs",
46+
}
47+
48+
NO_ID_RESPONSE = {
49+
"Name": "fargateapp",
50+
"DockerName": "fargateapp",
51+
"Image": "123456789012.dkr.ecr.us-west-2.amazonaws.com/fargatetest:latest",
52+
"ImageID": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd",
53+
"Labels": {
54+
"com.amazonaws.ecs.cluster": "arn:aws:ecs:us-west-2:123456789012:cluster/testcluster",
55+
"com.amazonaws.ecs.container-name": "fargateapp",
56+
"com.amazonaws.ecs.task-arn": "arn:aws:ecs:us-west-2:123456789012:task/testcluster/1e1698469422439ea356071e581e8545",
57+
"com.amazonaws.ecs.task-definition-family": "fargatetestapp",
58+
"com.amazonaws.ecs.task-definition-version": "7",
59+
},
60+
"DesiredStatus": "RUNNING",
61+
"KnownStatus": "RUNNING",
62+
"Limits": {"CPU": 2},
63+
"CreatedAt": "2024-04-25T17:38:31.073208914Z",
64+
"StartedAt": "2024-04-25T17:38:31.073208914Z",
65+
"Type": "NORMAL",
66+
"LogDriver": "awslogs",
67+
"LogOptions": {
68+
"awslogs-create-group": "true",
69+
"awslogs-group": "/ecs/fargatetestapp",
70+
"awslogs-region": "us-west-2",
71+
"awslogs-stream": "ecs/fargateapp/1e1698469422439ea356071e581e8545",
72+
},
73+
"ContainerARN": "arn:aws:ecs:us-west-2:123456789012:container/testcluster/1e1698469422439ea356071e581e8545/050256a5-a7f3-461c-a16f-aca4eae37b01",
74+
"Networks": [
75+
{
76+
"NetworkMode": "awsvpc",
77+
"IPv4Addresses": ["10.10.10.10"],
78+
"AttachmentIndex": 0,
79+
"MACAddress": "06:d7:3f:49:1d:a7",
80+
"IPv4SubnetCIDRBlock": "10.10.10.0/20",
81+
"DomainNameServers": ["10.10.10.2"],
82+
"DomainNameSearchList": ["us-west-2.compute.internal"],
83+
"PrivateDNSName": "ip-10-10-10-10.us-west-2.compute.internal",
84+
"SubnetGatewayIpv4Address": "10.10.10.1/20",
85+
}
86+
],
87+
"Snapshotter": "overlayfs",
88+
}
89+
90+
91+
def simple_get(self):
92+
response = json.dumps(STANDARD_RESPONSE).encode("utf-8")
93+
self.send_response(200)
94+
self.end_headers()
95+
self.wfile.write(response)
96+
97+
98+
def bad_response_get(self):
99+
response = json.dumps(NO_ID_RESPONSE).encode("utf-8")
100+
self.send_response(200)
101+
self.end_headers()
102+
self.wfile.write(response)
103+
104+
105+
@pytest.fixture(scope="function")
106+
def mock_server():
107+
with MockExternalHTTPServer(handler=simple_get) as mock_server:
108+
yield mock_server
109+
110+
111+
@pytest.fixture(scope="function")
112+
def bad_response_mock_server():
113+
with MockExternalHTTPServer(handler=bad_response_get) as bad_response_mock_server:
114+
yield bad_response_mock_server

0 commit comments

Comments
 (0)