Skip to content

Commit a03f73f

Browse files
committed
Add support for ECS container IDs.
1 parent ec59999 commit a03f73f

File tree

6 files changed

+203
-27
lines changed

6 files changed

+203
-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+
docker_id = cls.get_ecs_docker_id(uri)
343+
if docker_id:
344+
return docker_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+
docker_id = cls.get_ecs_docker_id(uri)
355+
if docker_id:
356+
return docker_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_docker_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(f"Unable to fetch Docker ID from ECS endpoint: {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
@@ -42,7 +42,7 @@
4242

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

118118
@pytest.fixture(autouse=True)
119119
def override_utilization(monkeypatch):
120-
global AWS, AZURE, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES
120+
global AWS, AZURE, ECS, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES
121121
AWS = {"id": "foo", "type": "bar", "zone": "baz"}
122122
AZURE = {"location": "foo", "name": "bar", "vmId": "baz", "vmSize": "boo"}
123+
ECS = {"ecsDockerId": "foobar"}
123124
GCP = {"id": 1, "machineType": "trmntr-t1000", "name": "arnold", "zone": "abc"}
124125
PCF = {"cf_instance_guid": "1", "cf_instance_ip": "7", "memory_limit": "0"}
125126
BOOT_ID = "cca356a7d72737f645a10c122ebbe906"
@@ -134,8 +135,12 @@ def detect(cls):
134135
output = BOOT_ID
135136
elif name.startswith("AWS"):
136137
output = AWS
138+
elif name.startswith("ECS"):
139+
output = ECS
137140
elif name.startswith("Azure"):
138141
output = AZURE
142+
elif name.startswith("ECS"):
143+
output = ECS
139144
elif name.startswith("GCP"):
140145
output = GCP
141146
elif name.startswith("PCF"):
@@ -290,6 +295,7 @@ def test_close_connection():
290295
def connect_payload_asserts(
291296
payload,
292297
with_aws=True,
298+
with_ecs=True,
293299
with_gcp=True,
294300
with_pcf=True,
295301
with_azure=True,
@@ -332,7 +338,7 @@ def connect_payload_asserts(
332338
else:
333339
assert "ip_address" not in payload_data["utilization"]
334340

335-
utilization_len = utilization_len + any([with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes])
341+
utilization_len = utilization_len + any([with_aws, with_ecs, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes])
336342
assert len(payload_data["utilization"]) == utilization_len
337343
assert payload_data["utilization"]["hostname"] == HOST
338344

@@ -349,11 +355,13 @@ def connect_payload_asserts(
349355
assert harvest_limits["error_event_data"] == ERROR_EVENT_DATA
350356

351357
vendors_len = 0
352-
353358
if any([with_aws, with_pcf, with_gcp, with_azure]):
354359
vendors_len += 1
355360

356-
if with_docker:
361+
if with_ecs:
362+
vendors_len += 1
363+
364+
if with_docker and not with_ecs:
357365
vendors_len += 1
358366

359367
if with_kubernetes:
@@ -372,7 +380,10 @@ def connect_payload_asserts(
372380
elif with_azure:
373381
assert payload_data["utilization"]["vendors"]["azure"] == AZURE
374382

375-
if with_docker:
383+
if with_ecs:
384+
assert payload_data["utilization"]["vendors"]["ecs"] == ECS
385+
386+
if with_docker and not with_ecs:
376387
assert payload_data["utilization"]["vendors"]["docker"] == DOCKER
377388

378389
if with_kubernetes:
@@ -382,24 +393,25 @@ def connect_payload_asserts(
382393

383394

384395
@pytest.mark.parametrize(
385-
"with_aws,with_pcf,with_gcp,with_azure,with_docker,with_kubernetes,with_ip",
396+
"with_aws,with_ecs,with_pcf,with_gcp,with_azure,with_docker,with_kubernetes,with_ip",
386397
[
387-
(False, False, False, False, False, False, False),
388-
(False, False, False, False, False, False, True),
389-
(True, False, False, False, True, True, True),
390-
(False, True, False, False, True, True, True),
391-
(False, False, True, False, True, True, True),
392-
(False, False, False, True, True, True, True),
393-
(True, False, False, False, False, False, True),
394-
(False, True, False, False, False, False, True),
395-
(False, False, True, False, False, False, True),
396-
(False, False, False, True, False, False, True),
397-
(True, True, True, True, True, True, True),
398-
(True, True, True, True, True, False, True),
399-
(True, True, True, True, False, True, True),
398+
(False, False, False, False, False, False, False, False),
399+
(False, False, False, False, False, False, False, True),
400+
(True, True, False, False, False, True, False, True),
401+
(True, True, False, False, False, True, True, True),
402+
(False, False, True, False, False, True, True, True),
403+
(False, False, False, True, False, True, True, True),
404+
(False, False, False, False, True, True, True, True),
405+
(True, True, False, False, False, False, False, True),
406+
(False, False, True, False, False, False, False, True),
407+
(False, False, False, True, False, False, False, True),
408+
(False, False, False, False, True, False, False, True),
409+
(True, True, True, True, True, True, True, True),
410+
(True, True, True, True, True, True, False, True),
411+
(True, True, True, True, True, False, True, True),
400412
],
401413
)
402-
def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes, with_ip):
414+
def test_connect(with_aws, with_ecs, with_pcf, with_gcp, with_azure, with_docker, with_kubernetes, with_ip):
403415
global AWS, AZURE, GCP, PCF, BOOT_ID, DOCKER, KUBERNETES, IP_ADDRESS
404416
if not with_aws:
405417
AWS = Exception
@@ -409,6 +421,8 @@ def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kub
409421
GCP = Exception
410422
if not with_azure:
411423
AZURE = Exception
424+
if not with_ecs:
425+
ECS = Exception
412426
if not with_docker:
413427
DOCKER = Exception
414428
if not with_kubernetes:
@@ -466,6 +480,7 @@ def test_connect(with_aws, with_pcf, with_gcp, with_azure, with_docker, with_kub
466480
with_pcf=with_pcf,
467481
with_gcp=with_gcp,
468482
with_azure=with_azure,
483+
with_ecs=with_ecs,
469484
with_docker=with_docker,
470485
with_kubernetes=with_kubernetes,
471486
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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": {
21+
"CPU": 2
22+
},
23+
"CreatedAt": "2024-04-25T17:38:31.073208914Z",
24+
"StartedAt": "2024-04-25T17:38:31.073208914Z",
25+
"Type": "NORMAL",
26+
"LogDriver": "awslogs",
27+
"LogOptions": {
28+
"awslogs-create-group": "true",
29+
"awslogs-group": "/ecs/fargatetestapp",
30+
"awslogs-region": "us-west-2",
31+
"awslogs-stream": "ecs/fargateapp/1e1698469422439ea356071e581e8545"
32+
},
33+
"ContainerARN": "arn:aws:ecs:us-west-2:123456789012:container/testcluster/1e1698469422439ea356071e581e8545/050256a5-a7f3-461c-a16f-aca4eae37b01",
34+
"Networks": [
35+
{
36+
"NetworkMode": "awsvpc",
37+
"IPv4Addresses": [
38+
"10.10.10.10"
39+
],
40+
"AttachmentIndex": 0,
41+
"MACAddress": "06:d7:3f:49:1d:a7",
42+
"IPv4SubnetCIDRBlock": "10.10.10.0/20",
43+
"DomainNameServers": [
44+
"10.10.10.2"
45+
],
46+
"DomainNameSearchList": [
47+
"us-west-2.compute.internal"
48+
],
49+
"PrivateDNSName": "ip-10-10-10-10.us-west-2.compute.internal",
50+
"SubnetGatewayIpv4Address": "10.10.10.1/20"
51+
}
52+
],
53+
"Snapshotter": "overlayfs"
54+
}
55+
56+
57+
def simple_get(self):
58+
response = json.dumps(STANDARD_RESPONSE).encode('utf-8')
59+
self.send_response(200)
60+
self.end_headers()
61+
self.wfile.write(response)
62+
63+
64+
@pytest.fixture(scope="session")
65+
def mock_server():
66+
with MockExternalHTTPServer(handler=simple_get) as mock_server:
67+
yield mock_server

tests/cross_agent/test_ecs_data.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import pytest
17+
import newrelic.common.utilization as u
18+
from tests.cross_agent.fixtures.ecs_container_id.ecs_mock_server import mock_server
19+
from test_pcf_utilization_data import Environ
20+
21+
22+
@pytest.mark.parametrize("env_key", ["ECS_CONTAINER_METADATA_URI_V4", "ECS_CONTAINER_METADATA_URI"])
23+
def test_ecs_docker_container_id(env_key, mock_server):
24+
mock_endpoint = 'http://localhost:%d' % mock_server.port
25+
env_dict = {env_key: mock_endpoint}
26+
27+
with Environ(env_dict):
28+
data = u.ECSUtilization.detect()
29+
assert data == {'ecsDockerId': '1e1698469422439ea356071e581e8545-2769485393'}
30+
31+
32+
def test_ecs_container_id_no_metadata_env_vars():
33+
assert u.ECSUtilization.detect() is None
34+

tests/cross_agent/test_utilization_configs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ def _patch_system_info(wrapped, instance, args, kwargs):
179179

180180
@pytest.mark.parametrize("test", _load_tests())
181181
def test_utilization_settings(test, monkeypatch):
182-
183182
env = test.get("input_environment_variables", {})
184183

185184
if test.get("input_pcf_guid"):

0 commit comments

Comments
 (0)