Repository: cloud-custodian/cloud-custodian

Issue:
ecs-service and ecs-task resources do not support cloudtrail policies
When validating a policy using cloudtrail mode on ecs-service or ecs-task resources an ERROR is returned saying "resource:ecs-service does not support cloudtrail mode policies"


Discussion:
yeah thats an explicit safety check, resources that require a parent/container to query them need explicit support for trail events as they would otherwise need to naively query too many resources. The ids expression in a trail event policy cant express capturing both the resource id and its parent id.  what's your use case around doing a cloudtrail event policy around ecs, in particular which api calls are you  interested in?

for ecs in particular its probably going to be easier for us to just support the ecs container events, but that won't capture on ecs service.
I'm interested in CreateService and UpdateService API calls for ecs-service, and RunTask for ecs-task. I want to see if the service or task is assigned a public IP.
this _should_ be doable now as the new task arns have the cluster name embedded, we should be able to get just the task/service/container instance without getting all of the parents and all of the children

Resolving patch:
diff --git a/c7n/resources/ecs.py b/c7n/resources/ecs.py
--- a/c7n/resources/ecs.py
+++ b/c7n/resources/ecs.py
@@ -16,6 +16,7 @@
 from botocore.exceptions import ClientError
 
 from c7n.actions import BaseAction
+from c7n.exceptions import PolicyExecutionError
 from c7n.filters import MetricsFilter, ValueFilter
 from c7n.manager import resources
 from c7n.utils import local_session, chunks, get_retry, type_schema, group_by
@@ -91,6 +92,27 @@ def __init__(self, manager):
             self.manager.session_factory, self.manager)
         self.query.capture_parent_id = True
 
+    def get_resources(self, ids, cache=True):
+        """Retrieve ecs resources for serverless policies or related resources
+
+        Requires arns in new format.
+        https://docs.aws.amazon.com/AmazonECS/latest/userguide/ecs-resource-ids.html
+        """
+        cluster_resources = {}
+        for i in ids:
+            _, ident = i.rsplit(':', 1)
+            parts = ident.split('/', 2)
+            if len(parts) != 3:
+                raise PolicyExecutionError("New format ecs arn required")
+            cluster_resources.setdefault(parts[1], []).append(parts[2])
+
+        results = []
+        client = local_session(self.manager.session_factory).client('ecs')
+        for cid, resource_ids in cluster_resources.items():
+            results.extend(
+                self.process_cluster_resources(client, cid, resource_ids))
+        return results
+
     def augment(self, resources):
         parent_child_map = {}
         for pid, r in resources:
@@ -143,6 +165,8 @@ class resource_type(object):
         enum_spec = ('list_services', 'serviceArns', None)
         parent_spec = ('ecs', 'cluster', None)
         dimension = None
+        supports_trailevents = True
+        filter_name = None
 
     @property
     def source_type(self):
@@ -151,6 +175,9 @@ def source_type(self):
             source = 'describe-ecs-service'
         return source
 
+    def get_resources(self, ids, cache=True, augment=True):
+        return super(Service, self).get_resources(ids, cache, augment=False)
+
 
 @Service.filter_registry.register('metrics')
 class ServiceMetrics(MetricsFilter):
@@ -273,6 +300,8 @@ class resource_type(object):
         enum_spec = ('list_tasks', 'taskArns', None)
         parent_spec = ('ecs', 'cluster', None)
         dimension = None
+        supports_trailevents = True
+        filter_name = None
 
     @property
     def source_type(self):
@@ -281,6 +310,9 @@ def source_type(self):
             source = 'describe-ecs-task'
         return source
 
+    def get_resources(self, ids, cache=True, augment=True):
+        return super(Task, self).get_resources(ids, cache, augment=False)
+
 
 @Task.filter_registry.register('task-definition')
 class TaskTaskDefinitionFilter(RelatedTaskDefinitionFilter):


Test patch:
diff --git a/tests/data/placebo/test_ecs_service_by_arn/ecs.DescribeServices_1.json b/tests/data/placebo/test_ecs_service_by_arn/ecs.DescribeServices_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_ecs_service_by_arn/ecs.DescribeServices_1.json
@@ -0,0 +1,133 @@
+{
+    "status_code": 200,
+    "data": {
+        "services": [
+            {
+                "serviceArn": "arn:aws:ecs:us-east-1:644160558196:service/test/test-no-delete",
+                "serviceName": "test-no-delete",
+                "clusterArn": "arn:aws:ecs:us-east-1:644160558196:cluster/test",
+                "loadBalancers": [],
+                "serviceRegistries": [],
+                "status": "ACTIVE",
+                "desiredCount": 0,
+                "runningCount": 0,
+                "pendingCount": 0,
+                "launchType": "FARGATE",
+                "platformVersion": "LATEST",
+                "taskDefinition": "arn:aws:ecs:us-east-1:644160558196:task-definition/task-public-no-vpc:1",
+                "deploymentConfiguration": {
+                    "maximumPercent": 200,
+                    "minimumHealthyPercent": 0
+                },
+                "deployments": [
+                    {
+                        "id": "ecs-svc/9223370488569269184",
+                        "status": "PRIMARY",
+                        "taskDefinition": "arn:aws:ecs:us-east-1:644160558196:task-definition/task-public-no-vpc:1",
+                        "desiredCount": 0,
+                        "pendingCount": 0,
+                        "runningCount": 0,
+                        "createdAt": {
+                            "__class__": "datetime",
+                            "year": 2019,
+                            "month": 1,
+                            "day": 23,
+                            "hour": 18,
+                            "minute": 18,
+                            "second": 26,
+                            "microsecond": 603000
+                        },
+                        "updatedAt": {
+                            "__class__": "datetime",
+                            "year": 2019,
+                            "month": 1,
+                            "day": 23,
+                            "hour": 18,
+                            "minute": 18,
+                            "second": 32,
+                            "microsecond": 754000
+                        },
+                        "launchType": "FARGATE",
+                        "platformVersion": "1.3.0",
+                        "networkConfiguration": {
+                            "awsvpcConfiguration": {
+                                "subnets": [
+                                    "subnet-07c118e47bb84cee7"
+                                ],
+                                "securityGroups": [
+                                    "sg-0fd0c04f0c434d68d"
+                                ],
+                                "assignPublicIp": "DISABLED"
+                            }
+                        }
+                    }
+                ],
+                "roleArn": "arn:aws:iam::644160558196:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
+                "events": [
+                    {
+                        "id": "9aa2d0a2-3ce3-4ab9-8ccd-233f3bd3a700",
+                        "createdAt": {
+                            "__class__": "datetime",
+                            "year": 2019,
+                            "month": 1,
+                            "day": 26,
+                            "hour": 12,
+                            "minute": 20,
+                            "second": 38,
+                            "microsecond": 481000
+                        },
+                        "message": "(service test-no-delete) has reached a steady state."
+                    }
+                ],
+                "createdAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 23,
+                    "hour": 18,
+                    "minute": 18,
+                    "second": 26,
+                    "microsecond": 603000
+                },
+                "placementConstraints": [],
+                "placementStrategy": [],
+                "networkConfiguration": {
+                    "awsvpcConfiguration": {
+                        "subnets": [
+                            "subnet-07c118e47bb84cee7"
+                        ],
+                        "securityGroups": [
+                            "sg-0fd0c04f0c434d68d"
+                        ],
+                        "assignPublicIp": "DISABLED"
+                    }
+                },
+                "schedulingStrategy": "REPLICA",
+                "tags": [
+                    {
+                        "key": "Env",
+                        "value": "Dev"
+                    },
+                    {
+                        "key": "Owner",
+                        "value": "1"
+                    }
+                ],
+                "enableECSManagedTags": true,
+                "propagateTags": "NONE"
+            }
+        ],
+        "failures": [],
+        "ResponseMetadata": {
+            "RequestId": "b56edd9b-22fe-11e9-a152-0bf0ed8e028d",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "b56edd9b-22fe-11e9-a152-0bf0ed8e028d",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "4171",
+                "date": "Mon, 28 Jan 2019 13:14:56 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_ecs_task_by_arn/ecs.DescribeTasks_1.json b/tests/data/placebo/test_ecs_task_by_arn/ecs.DescribeTasks_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_by_arn/ecs.DescribeTasks_1.json
@@ -0,0 +1,155 @@
+{
+    "status_code": 200,
+    "data": {
+        "tasks": [
+            {
+                "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/devx/21b23041dec947b996fcc7a8aa606d64",
+                "clusterArn": "arn:aws:ecs:us-east-1:644160558196:cluster/devx",
+                "taskDefinitionArn": "arn:aws:ecs:us-east-1:644160558196:task-definition/task-public-no-vpc:1",
+                "overrides": {
+                    "containerOverrides": [
+                        {
+                            "name": "webserver"
+                        }
+                    ]
+                },
+                "lastStatus": "STOPPED",
+                "desiredStatus": "STOPPED",
+                "cpu": "256",
+                "memory": "1024",
+                "containers": [
+                    {
+                        "containerArn": "arn:aws:ecs:us-east-1:644160558196:container/98fea76b-3966-4c85-97df-f133de0c903b",
+                        "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/devx/21b23041dec947b996fcc7a8aa606d64",
+                        "name": "webserver",
+                        "lastStatus": "STOPPED",
+                        "reason": "CannotPullContainerError: API error (500): Get https://registry-1.docker.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)\n",
+                        "networkInterfaces": [
+                            {
+                                "attachmentId": "8755dae0-550b-4134-93a2-ae087d93561a",
+                                "privateIpv4Address": "10.0.42.109"
+                            }
+                        ],
+                        "healthStatus": "UNKNOWN"
+                    }
+                ],
+                "version": 4,
+                "stoppedReason": "Task failed to start",
+                "stopCode": "TaskFailedToStart",
+                "connectivity": "CONNECTED",
+                "connectivityAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 7,
+                    "minute": 59,
+                    "second": 15,
+                    "microsecond": 254000
+                },
+                "pullStartedAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 7,
+                    "minute": 59,
+                    "second": 28,
+                    "microsecond": 542000
+                },
+                "pullStoppedAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 8,
+                    "minute": 1,
+                    "second": 51,
+                    "microsecond": 542000
+                },
+                "executionStoppedAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 8,
+                    "minute": 1,
+                    "second": 51,
+                    "microsecond": 0
+                },
+                "createdAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 7,
+                    "minute": 59,
+                    "second": 11,
+                    "microsecond": 710000
+                },
+                "stoppingAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 8,
+                    "minute": 1,
+                    "second": 51,
+                    "microsecond": 542000
+                },
+                "stoppedAt": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 1,
+                    "day": 28,
+                    "hour": 8,
+                    "minute": 2,
+                    "second": 24,
+                    "microsecond": 674000
+                },
+                "group": "family:task-public-no-vpc",
+                "launchType": "FARGATE",
+                "platformVersion": "1.3.0",
+                "attachments": [
+                    {
+                        "id": "8755dae0-550b-4134-93a2-ae087d93561a",
+                        "type": "ElasticNetworkInterface",
+                        "status": "DELETED",
+                        "details": [
+                            {
+                                "name": "subnetId",
+                                "value": "subnet-71bda45c"
+                            },
+                            {
+                                "name": "networkInterfaceId",
+                                "value": "eni-0ef479fe627e8ccf5"
+                            },
+                            {
+                                "name": "macAddress",
+                                "value": "12:11:c4:f4:0d:42"
+                            },
+                            {
+                                "name": "privateIPv4Address",
+                                "value": "10.0.42.109"
+                            }
+                        ]
+                    }
+                ],
+                "healthStatus": "UNKNOWN",
+                "tags": []
+            }
+        ],
+        "failures": [],
+        "ResponseMetadata": {
+            "RequestId": "8056c0e7-22fd-11e9-9219-6f00e80aeaf6",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "8056c0e7-22fd-11e9-9219-6f00e80aeaf6",
+                "content-type": "application/x-amz-json-1.1",
+                "content-length": "1791",
+                "date": "Mon, 28 Jan 2019 13:06:18 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/test_ecs.py b/tests/test_ecs.py
--- a/tests/test_ecs.py
+++ b/tests/test_ecs.py
@@ -19,6 +19,8 @@
 import os
 import time
 
+from c7n.exceptions import PolicyExecutionError
+
 
 class TestEcsService(BaseTest):
 
@@ -48,6 +50,24 @@ def test_ecs_service_tag_augment(self):
             resources[0]['Tags'],
             [{'Key': 'Name', 'Value': 'Dev'}])
 
+    def test_ecs_service_by_arn(self):
+        session_factory = self.replay_flight_data('test_ecs_service_by_arn')
+
+        p = self.load_policy({
+            'name': 'ecs-svc', 'resource': 'ecs-service'},
+            session_factory=session_factory)
+        svcs = p.resource_manager.get_resources(
+            ["arn:aws:ecs:us-east-1:644160558196:service/test/test-no-delete"])
+        self.assertEqual(len(svcs), 1)
+        self.assertEqual(
+            {t['Key']: t['Value'] for t in svcs[0]['Tags']},
+            {'Env': 'Dev', 'Owner': '1'})
+
+        self.assertRaises(
+            PolicyExecutionError,
+            p.resource_manager.get_resources,
+            ["arn:aws:ecs:us-east-1:644160558196:service/test-no-delete"])
+
     def test_ecs_service_resource(self):
         session_factory = self.replay_flight_data("test_ecs_service")
         p = self.load_policy(
@@ -198,6 +218,21 @@ def test_task_definition_get_resources(self):
 
 class TestEcsTask(BaseTest):
 
+    def test_task_by_arn(self):
+        session_factory = self.replay_flight_data('test_ecs_task_by_arn')
+        p = self.load_policy({
+            'name': 'tasks', 'resource': 'ecs-task'}, session_factory=session_factory)
+        tasks = p.resource_manager.get_resources([
+            'arn:aws:ecs:us-east-1:644160558196:task/devx/21b23041dec947b996fcc7a8aa606d64'])
+        self.assertEqual(len(tasks), 1)
+        self.assertEqual(tasks[0]['launchType'], 'FARGATE')
+        self.assertEqual(tasks[0]['lastStatus'], 'STOPPED')
+
+        self.assertRaises(
+            PolicyExecutionError,
+            p.resource_manager.get_resources,
+            ['arn:aws:ecs:us-east-1:644160558196:task/21b23041dec947b996fcc7a8aa606d64'])
+
     def test_task_resource(self):
         session_factory = self.replay_flight_data("test_ecs_task")
         p = self.load_policy(