Repository: cloud-custodian/cloud-custodian

Issue:
aws.ecs-task subnet filter
**Is your feature request related to a problem? Please describe.**
Want to lock all singleton tasks to a private subnet. 

**Describe the solution you'd like**
Would be nice if I could add a subnet filter for ecs-task. Although, it not very clearly mentioned in the docs, but the describe_tasks api returns back ENI details that also includes subnet id in it.
```
  "attachments": [
                {
                    "id": "a1b2c3d4-5678-90ab-cdef-33333EXAMPLE",
                    "type": "ElasticNetworkInterface",
                    "status": "ATTACHED",
                    "details": [
                        {
                            "name": "subnetId",
                            "value": "subnet-12344321"
                        },
                        {
                            "name": "networkInterfaceId",
                            "value": "eni-12344321"
                        },
                        {
                            "name": "macAddress",
                            "value": "0a:90:09:84:f9:14"
                        },
                        {
                            "name": "privateIPv4Address",
                            "value": "10.0.0.4"
                        }
                    ]
                }
            ],
```
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#ECS.Client.describe_tasks

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


Discussion:

Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..4054d02e9
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,16 @@
+{
+	"name": "Cloud Custodian Development",
+	
+	"image": "python:3.8-buster",
+
+	// Set *default* container specific settings.json values on container create.
+	"settings": {
+		"python.linting.pylintEnabled": false,
+		"python.linting.flake8Enabled": true,
+		"python.linting.enabled": true
+	},
+
+	"extensions": ["ms-python.python"],
+
+	"postCreateCommand": "cd .devcontainer && ./prep_container.sh",
+}
\ No newline at end of file
diff --git a/.devcontainer/prep_container.sh b/.devcontainer/prep_container.sh
new file mode 100644
index 000000000..adbe783aa
--- /dev/null
+++ b/.devcontainer/prep_container.sh
@@ -0,0 +1,19 @@
+#!/bin/bash -e
+set -x
+
+# Workspace
+cd ..
+
+# Install prerequisites
+pip3 install -U virtualenv tox
+
+# Poetry
+curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
+source $HOME/.poetry/env
+
+# Virtual env
+python3 -m venv .venv
+source .venv/bin/activate
+
+# Install
+make install-poetry
diff --git a/c7n/resources/ecs.py b/c7n/resources/ecs.py
index ae6326157..8b86c7526 100644
--- a/c7n/resources/ecs.py
+++ b/c7n/resources/ecs.py
@@ -418,6 +418,12 @@ class Task(query.ChildResourceManager):
         return super(Task, self).get_resources(ids, cache, augment=False)
 
 
+@Task.filter_registry.register('subnet')
+class TaskSubnetFilter(net_filters.SubnetFilter):
+
+    RelatedIdsExpression = "attachments[].details[?name == 'subnetId'].value[]"
+
+
 @Task.filter_registry.register('task-definition')
 class TaskTaskDefinitionFilter(RelatedTaskDefinitionFilter):
     """Filter tasks by their task definition.
diff --git a/c7n/resources/qldb.py b/c7n/resources/qldb.py
index 4177eed34..6d3aad2cd 100644
--- a/c7n/resources/qldb.py
+++ b/c7n/resources/qldb.py
@@ -28,7 +28,6 @@ class QLDB(QueryResourceManager):
         date = 'CreationDateTime'
         universal_taggable = object()
         cfn_type = config_type = 'AWS::QLDB::Ledger'
-        not_found_err = 'ResourceNotFoundException'
 
     source_mapping = {
         'describe': DescribeQLDB,
@@ -51,14 +50,14 @@ class Delete(Action):
                     client.update_ledger(
                         Name=r['Name'],
                         DeletionProtection=False)
-                except self.manager.resource_type.not_found_err:
+                except client.exceptions.ResourceNotFoundException:  # pragma: no cover
                     continue
             elif r.get('DeletionProtection'):
                 protected += 1
                 continue
             try:
                 client.delete_ledger(Name=r['Name'])
-            except self.manager.resource_type.not_found_err:
+            except client.exceptions.ResourceNotFoundException:  # pragma: no cover
                 continue
         if protected:
             self.log.warning((
diff --git a/tools/dev/docker_tox_linux/Dockerfile b/tools/dev/docker_tox_linux/Dockerfile
index 33b88ebad..dcc1b4c05 100644
--- a/tools/dev/docker_tox_linux/Dockerfile
+++ b/tools/dev/docker_tox_linux/Dockerfile
@@ -18,7 +18,7 @@
 
 
 
-FROM python:3.7-stretch
+FROM python:3.8-buster
 
 LABEL name="custodian tox" \
       description="Run Custodian test suite" \
@@ -30,9 +30,9 @@ RUN pip3 install tox
 
 ADD . /src
 WORKDIR /src
-RUN tox -e py37 --notest
+RUN tox -e py38 --notest
 
-ARG TOX_ENV=py37
+ARG TOX_ENV=py38
 
 # Setup for EntryPoint
 ENV LC_ALL="C.UTF-8" LANG="C.UTF-8" TOX_ENV_ENV=$TOX_ENV
diff --git a/tools/dev/docker_tox_windows/Dockerfile b/tools/dev/docker_tox_windows/Dockerfile
index ad72a8839..4c2e7b85c 100644
--- a/tools/dev/docker_tox_windows/Dockerfile
+++ b/tools/dev/docker_tox_windows/Dockerfile
@@ -4,7 +4,7 @@
 # Change working directory to the repository root and run:
 # docker build -t custodian_tox_win . -f tools/dev/docker_tox_windows/Dockerfile && docker run -it custodian_tox_win
 
-FROM python:3.7-windowsservercore
+FROM python:3.8-windowsservercore
 
 LABEL name="custodian tox windows" \
       description="Run Custodian test suite" \
@@ -19,4 +19,4 @@ ADD . /src
 # Setup for EntryPoint
 ENV LC_ALL="C.UTF-8" LANG="C.UTF-8"
 WORKDIR /src
-ENTRYPOINT ["tox", "-e", "py37"]
+ENTRYPOINT ["tox", "-e", "py38"]


Test patch:
diff --git a/tests/data/placebo/test_ecs_task_subnet/ec2.DescribeSubnets_1.json b/tests/data/placebo/test_ecs_task_subnet/ec2.DescribeSubnets_1.json
new file mode 100644
index 000000000..c68f684c0
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ec2.DescribeSubnets_1.json
@@ -0,0 +1,56 @@
+{
+    "status_code": 200,
+    "data": {
+        "Subnets": [
+            {
+                "AvailabilityZone": "us-east-1b",
+                "AvailabilityZoneId": "use1-az6",
+                "AvailableIpAddressCount": 250,
+                "CidrBlock": "10.0.0.0/24",
+                "DefaultForAz": false,
+                "MapPublicIpOnLaunch": false,
+                "MapCustomerOwnedIpOnLaunch": false,
+                "State": "available",
+                "SubnetId": "subnet-05b58b4afe5124322",
+                "VpcId": "vpc-0a59f94e3fca9fbc0",
+                "OwnerId": "644160558196",
+                "AssignIpv6AddressOnCreation": false,
+                "Ipv6CidrBlockAssociationSet": [],
+                "Tags": [
+                    {
+                        "Key": "Name",
+                        "Value": "implied"
+                    }
+                ],
+                "SubnetArn": "arn:aws:ec2:us-east-1:644160558196:subnet/subnet-05b58b4afe5124322"
+            },
+            {
+                "AvailabilityZone": "us-east-1a",
+                "AvailabilityZoneId": "use1-az4",
+                "AvailableIpAddressCount": 4081,
+                "CidrBlock": "10.205.0.0/20",
+                "DefaultForAz": false,
+                "MapPublicIpOnLaunch": true,
+                "MapCustomerOwnedIpOnLaunch": false,
+                "State": "available",
+                "SubnetId": "subnet-07c118e47bb84cee7",
+                "VpcId": "vpc-03005fb9b8740263d",
+                "OwnerId": "644160558196",
+                "AssignIpv6AddressOnCreation": false,
+                "Ipv6CidrBlockAssociationSet": [],
+                "Tags": [
+                    {
+                        "Key": "NetworkLocation",
+                        "Value": "Public"
+                    },
+                    {
+                        "Key": "Name",
+                        "Value": "testing-subnet"
+                    }
+                ],
+                "SubnetArn": "arn:aws:ec2:us-east-1:644160558196:subnet/subnet-07c118e47bb84cee7"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.DescribeTasks_1.json b/tests/data/placebo/test_ecs_task_subnet/ecs.DescribeTasks_1.json
new file mode 100644
index 000000000..1fc136e2e
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.DescribeTasks_1.json
@@ -0,0 +1,173 @@
+{
+    "status_code": 200,
+    "data": {
+        "tasks": [
+            {
+                "attachments": [
+                    {
+                        "id": "8808ac1b-278b-4763-8c5f-c6aaf3f10ebe",
+                        "type": "ElasticNetworkInterface",
+                        "status": "ATTACHING",
+                        "details": [
+                            {
+                                "name": "subnetId",
+                                "value": "subnet-07c118e47bb84cee7"
+                            },
+                            {
+                                "name": "networkInterfaceId",
+                                "value": "eni-0d77e756c403bc543"
+                            },
+                            {
+                                "name": "macAddress",
+                                "value": "0a:9e:9b:5e:43:8b"
+                            },
+                            {
+                                "name": "privateIPv4Address",
+                                "value": "10.205.2.28"
+                            }
+                        ]
+                    }
+                ],
+                "availabilityZone": "us-east-1a",
+                "capacityProviderName": "FARGATE_SPOT",
+                "clusterArn": "arn:aws:ecs:us-east-1:644160558196:cluster/c7n-fargate-test",
+                "containers": [
+                    {
+                        "containerArn": "arn:aws:ecs:us-east-1:644160558196:container/9868b138-b800-4c9a-89f9-bc35a80e041d",
+                        "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/575e66d2c7814af6aab4d3ec8ab58dab",
+                        "name": "fargate-app-2",
+                        "image": "httpd:2.4",
+                        "lastStatus": "PENDING",
+                        "networkInterfaces": [
+                            {
+                                "attachmentId": "8808ac1b-278b-4763-8c5f-c6aaf3f10ebe",
+                                "privateIpv4Address": "10.205.2.28"
+                            }
+                        ],
+                        "healthStatus": "UNKNOWN",
+                        "cpu": "0"
+                    }
+                ],
+                "cpu": "256",
+                "createdAt": {
+                    "__class__": "datetime",
+                    "year": 2021,
+                    "month": 2,
+                    "day": 23,
+                    "hour": 3,
+                    "minute": 27,
+                    "second": 17,
+                    "microsecond": 901000
+                },
+                "desiredStatus": "RUNNING",
+                "group": "family:app-fargate-task",
+                "healthStatus": "UNKNOWN",
+                "lastStatus": "PROVISIONING",
+                "launchType": "FARGATE",
+                "memory": "512",
+                "overrides": {
+                    "containerOverrides": [
+                        {
+                            "name": "fargate-app-2"
+                        }
+                    ],
+                    "inferenceAcceleratorOverrides": []
+                },
+                "platformVersion": "1.3.0",
+                "tags": [
+                    {
+                        "key": "aws:ecs:clusterName",
+                        "value": "c7n-fargate-test"
+                    }
+                ],
+                "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/575e66d2c7814af6aab4d3ec8ab58dab",
+                "taskDefinitionArn": "arn:aws:ecs:us-east-1:644160558196:task-definition/app-fargate-task:1",
+                "version": 1
+            },
+            {
+                "attachments": [
+                    {
+                        "id": "78c1846d-e6a5-4cd4-9149-89f56c35821f",
+                        "type": "ElasticNetworkInterface",
+                        "status": "ATTACHED",
+                        "details": [
+                            {
+                                "name": "subnetId",
+                                "value": "subnet-05b58b4afe5124322"
+                            },
+                            {
+                                "name": "networkInterfaceId",
+                                "value": "eni-0c3042130d331bd71"
+                            },
+                            {
+                                "name": "macAddress",
+                                "value": "0e:2e:e1:cc:ab:91"
+                            },
+                            {
+                                "name": "privateIPv4Address",
+                                "value": "10.0.0.57"
+                            }
+                        ]
+                    }
+                ],
+                "availabilityZone": "us-east-1b",
+                "capacityProviderName": "FARGATE_SPOT",
+                "clusterArn": "arn:aws:ecs:us-east-1:644160558196:cluster/c7n-fargate-test",
+                "containers": [
+                    {
+                        "containerArn": "arn:aws:ecs:us-east-1:644160558196:container/58f80ee5-f881-4cca-9f15-79f666ad1836",
+                        "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/ab3791544fcc4bd2a501edb673f037f6",
+                        "name": "fargate-app",
+                        "image": "httpd:2.4",
+                        "lastStatus": "PENDING",
+                        "networkInterfaces": [
+                            {
+                                "attachmentId": "78c1846d-e6a5-4cd4-9149-89f56c35821f",
+                                "privateIpv4Address": "10.0.0.57"
+                            }
+                        ],
+                        "healthStatus": "UNKNOWN",
+                        "cpu": "0"
+                    }
+                ],
+                "cpu": "256",
+                "createdAt": {
+                    "__class__": "datetime",
+                    "year": 2021,
+                    "month": 2,
+                    "day": 23,
+                    "hour": 3,
+                    "minute": 27,
+                    "second": 3,
+                    "microsecond": 213000
+                },
+                "desiredStatus": "RUNNING",
+                "group": "family:webapp-fargate-task",
+                "healthStatus": "UNKNOWN",
+                "lastStatus": "PENDING",
+                "launchType": "FARGATE",
+                "memory": "512",
+                "overrides": {
+                    "containerOverrides": [
+                        {
+                            "name": "fargate-app"
+                        }
+                    ],
+                    "inferenceAcceleratorOverrides": []
+                },
+                "platformVersion": "1.3.0",
+                "tags": [
+                    {
+                        "key": "aws:ecs:clusterName",
+                        "value": "c7n-fargate-test"
+                    }
+                ],
+                "taskArn": "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/ab3791544fcc4bd2a501edb673f037f6",
+                "taskDefinitionArn": "arn:aws:ecs:us-east-1:644160558196:task-definition/webapp-fargate-task:1",
+                "version": 2
+            }
+        ],
+        "failures": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListClusters_1.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListClusters_1.json
new file mode 100644
index 000000000..82ce5a418
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListClusters_1.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "clusterArns": [
+            "arn:aws:ecs:us-east-1:644160558196:cluster/Default",
+            "arn:aws:ecs:us-east-1:644160558196:cluster/default-cluster",
+            "arn:aws:ecs:us-east-1:644160558196:cluster/c7n-fargate-test",
+            "arn:aws:ecs:us-east-1:644160558196:cluster/c7n-fargate-test-2"
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_1.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_1.json
new file mode 100644
index 000000000..23b76f58f
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_2.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_2.json
new file mode 100644
index 000000000..23b76f58f
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_2.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_3.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_3.json
new file mode 100644
index 000000000..621f71ff6
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_3.json
@@ -0,0 +1,10 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [
+            "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/575e66d2c7814af6aab4d3ec8ab58dab",
+            "arn:aws:ecs:us-east-1:644160558196:task/c7n-fargate-test/ab3791544fcc4bd2a501edb673f037f6"
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_4.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_4.json
new file mode 100644
index 000000000..23b76f58f
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_4.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_5.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_5.json
new file mode 100644
index 000000000..23b76f58f
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_5.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_6.json b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_6.json
new file mode 100644
index 000000000..23b76f58f
--- /dev/null
+++ b/tests/data/placebo/test_ecs_task_subnet/ecs.ListTasks_6.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "taskArns": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_ecs.py b/tests/test_ecs.py
index 350035d88..fe5d0ddab 100644
--- a/tests/test_ecs.py
+++ b/tests/test_ecs.py
@@ -333,6 +333,27 @@ class TestEcsTask(BaseTest):
         resources = p.run()
         self.assertEqual(len(resources), 4)
 
+    def test_ecs_task_subnet(self):
+        session_factory = self.replay_flight_data("test_ecs_task_subnet")
+        p = self.load_policy(
+            {
+                "name": "ecs-task-fargate-subnets",
+                "resource": "ecs-task",
+                "filters": [
+                    {
+                        "type": "subnet",
+                        "key": "tag:Name",
+                        "value": "implied"
+                    }
+                ],
+            },
+            session_factory=session_factory,
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(resources[0].get('attachments')[0].get(
+            'details')[0].get('value'), "subnet-05b58b4afe5124322")
+
     def test_task_delete(self):
         session_factory = self.replay_flight_data("test_ecs_task_delete")
         p = self.load_policy(