Repository: cloud-custodian/cloud-custodian

Issue:
c7n: generic filter to limit and sort resources
**Is your feature request related to a problem? Please describe.**

I have over 500 resources returning when filtering for old unused AMIs and these are batched up in 3 different slack messages for 8 different regions which spams email and slack with 24 messages once a week.

I'd like to only use a subset of the resources like the top 10 oldest AMIs per region which should be less than the 250 batch limit which should decrease the 24 slack messages down to 8 slack messages (1 per region). I want to use `max-resources` but this option fails the policy. I'd like to limit the resources and sort them if possible.

**Describe the solution you'd like**
A `sort` filter to sort my resources and then a `max-resources` filter to limit the number of resources returned. This way I can sort by `CreationDate` and then set the `max-resources` to 10 resources

Is there a jmespath query to do this as a value filter in the policy itself?

**Describe alternatives you've considered**
I've tried filtering in the jinja template using python subset but this is applied after the batch limit so my jinja filtering happens on batched messages which is what I'm trying to avoid.

**Additional context**
Add any other context or screenshots about the feature request here.

Similar issue https://github.com/cloud-custodian/cloud-custodian/issues/4997 which uses `max-resources` but the resources aren't sorted.


Discussion:
max-resources isn't for just taking an arbitrary partial set from the filtered resources. its a safety breaker that stops policy execution before actions.

what i'm reading is that you want per say sql, you want to have the set ordered with a limit.

the reason why is because of wanting to limit reporting though which seems more akin to #5436 and that the requested filter is itself just a work around.
Ah ok I see that the `max-resources` is a safety breaker as in the policy will through ResourceLimitExceeded exception if there are more resources than the limit defined.

Is there some jmespath magic I can use with a `value` filter that can allow me to only grab a subset of resources?
Hey @nitrocode, you may have some luck with jmespath [slicing](https://jmespath.org/tutorial.html#slicing) and [functions](https://jmespath.org/tutorial.html#filter-projections) though I haven't played around with them enough to know if it will work at the policy level or if it will sort datetimes correctly.
@JohnHillegass I believe these would only work within the template. Can this be used to limit the number of resources from the policy itself? 
The AWS CLI allows for `--query`, which, with the suggestion from @JohnHillegass, would use this to return the oldest 10 images.
```
aws ec2 describe-images --query "sort_by(Images[], &CreationDate)[0:10]"
```
However, it appears to be a cli-level filter and not an aws api filter, so I don't think that would translate to a policy.
I'm looking for something similar ... in our case we're thinking about using it to introduce some chaos engineering.  would love to be able to just select a random 10 instances and shoot them in the head.  but we want a way to be able to make sure they're not all part of the same autoscaling group or something like that.  I was thinking about having a `reduce` filter that would allow you to `group` the instances, `sort` each group (or even randomize), and then `select` some number from each group (which might be an absolute count or percentage).  Something like:
```yaml
policies:
  - name: chaos
    resource: ec2
    filters:
      - "State.Name": "running"
      - "tag:aws:autoscaling:groupName": present
      - type: reduce
        group: "tag:aws:autoscaling:groupName"
        sort:
          key: "LaunchTime"
          order: asc
        select: 1
      - type: reduce
        sort: randomize
        select: 10%
    actions:
      - terminate
```
which would find the oldest instance from of each autoscaling group... then randomly select 10% of them, then terminate them.  Sound interesting to others here?

Resolving patch:
diff --git a/c7n/filters/__init__.py b/c7n/filters/__init__.py
--- a/c7n/filters/__init__.py
+++ b/c7n/filters/__init__.py
@@ -22,7 +22,9 @@
     Not,
     ValueFilter,
     AgeFilter,
-    EventFilter,)
+    EventFilter,
+    ReduceFilter,
+)
 from .config import ConfigCompliance
 from .health import HealthEventFilter
 from .iamaccess import CrossAccountAccessFilter, PolicyChecker
diff --git a/c7n/filters/core.py b/c7n/filters/core.py
--- a/c7n/filters/core.py
+++ b/c7n/filters/core.py
@@ -27,6 +27,7 @@
 from dateutil.tz import tzutc
 from dateutil.parser import parse
 from distutils import version
+from random import sample
 import jmespath
 
 from c7n.element import Element
@@ -123,6 +124,7 @@ def __init__(self, *args, **kw):
         self.register('and', And)
         self.register('not', Not)
         self.register('event', EventFilter)
+        self.register('reduce', ReduceFilter)
 
     def parse(self, data, manager):
         results = []
@@ -246,6 +248,43 @@ def merge_annotation(self, r, annotation_key, values):
             return
 
 
+class BaseValueFilter(Filter):
+    expr = None
+
+    def __init__(self, data, manager=None):
+        super(BaseValueFilter, self).__init__(data, manager)
+        self.expr = {}
+
+    def get_resource_value(self, k, i, regex=None):
+        r = None
+        if k.startswith('tag:'):
+            tk = k.split(':', 1)[1]
+            if 'Tags' in i:
+                for t in i.get("Tags", []):
+                    if t.get('Key') == tk:
+                        r = t.get('Value')
+                        break
+            # GCP schema: 'labels': {'key': 'value'}
+            elif 'labels' in i:
+                r = i.get('labels', {}).get(tk, None)
+            # GCP has a secondary form of labels called tags
+            # as labels without values.
+            # Azure schema: 'tags': {'key': 'value'}
+            elif 'tags' in i:
+                r = i.get('tags', {}).get(tk, None)
+        elif k in i:
+            r = i.get(k)
+        elif k not in self.expr:
+            self.expr[k] = jmespath.compile(k)
+            r = self.expr[k].search(i)
+        else:
+            r = self.expr[k].search(i)
+
+        if regex:
+            r = ValueRegex(regex).get_resource_value(r)
+        return r
+
+
 def intersect_list(a, b):
     if b is None:
         return a
@@ -395,10 +434,9 @@ def __eq__(self, other):
             return False
 
 
-class ValueFilter(Filter):
+class ValueFilter(BaseValueFilter):
     """Generic value filter using jmespath
     """
-    expr = None
     op = v = vtype = None
 
     schema = {
@@ -422,10 +460,6 @@ class ValueFilter(Filter):
     annotate = True
     required_keys = {'value', 'key'}
 
-    def __init__(self, data, manager=None):
-        super(ValueFilter, self).__init__(data, manager)
-        self.expr = {}
-
     def _validate_resource_count(self):
         """ Specific validation for `resource_count` type
 
@@ -486,11 +520,11 @@ def validate(self):
                     raise PolicyValidationError(
                         "Invalid regex: %s %s" % (e, self.data))
         if 'value_regex' in self.data:
-            return self._validate_value_regex()
+            return self._validate_value_regex(self.data['value_regex'])
 
         return self
 
-    def _validate_value_regex(self):
+    def _validate_value_regex(self, regex):
         """Specific validation for `value_regex` type
 
         The `value_regex` type works a little differently.  In
@@ -500,7 +534,7 @@ def _validate_value_regex(self):
         """
         # Sanity check that we can compile
         try:
-            pattern = re.compile(self.data['value_regex'])
+            pattern = re.compile(regex)
             if pattern.groups != 1:
                 raise PolicyValidationError(
                     "value_regex must have a single capturing group: %s" %
@@ -530,34 +564,7 @@ def process(self, resources, event=None):
         return super(ValueFilter, self).process(resources, event)
 
     def get_resource_value(self, k, i):
-        if k.startswith('tag:'):
-            tk = k.split(':', 1)[1]
-            r = None
-            if 'Tags' in i:
-                for t in i.get("Tags", []):
-                    if t.get('Key') == tk:
-                        r = t.get('Value')
-                        break
-            # GCP schema: 'labels': {'key': 'value'}
-            elif 'labels' in i:
-                r = i.get('labels', {}).get(tk, None)
-            # GCP has a secondary form of labels called tags
-            # as labels without values.
-            # Azure schema: 'tags': {'key': 'value'}
-            elif 'tags' in i:
-                r = i.get('tags', {}).get(tk, None)
-        elif k in i:
-            r = i.get(k)
-        elif k not in self.expr:
-            self.expr[k] = jmespath.compile(k)
-            r = self.expr[k].search(i)
-        else:
-            r = self.expr[k].search(i)
-
-        if 'value_regex' in self.data:
-            regex = ValueRegex(self.data['value_regex'])
-            r = regex.get_resource_value(r)
-        return r
+        return super(ValueFilter, self).get_resource_value(k, i, self.data.get('value_regex'))
 
     def match(self, i):
         if self.v is None and len(self.data) == 1:
@@ -816,3 +823,216 @@ def get_resource_value(self, resource):
         if capture is None:  # regex didn't capture anything
             return None
         return capture.group(1)
+
+
+class ReduceFilter(BaseValueFilter):
+    """Generic reduce filter to group, sort, and limit your resources.
+
+    This example will select the longest running instance from each ASG,
+    then randomly choose 10% of those, maxing at 15 total instances.
+
+    :example:
+
+    .. code-block:: yaml
+
+      - name: oldest-instance-by-asg
+        resource: ec2
+        filters:
+          - "tag:aws:autoscaling:groupName": present
+          - type: reduce
+            group-by: "tag:aws:autoscaling:groupName"
+            sort-by: "LaunchTime"
+            order: asc
+            limit: 1
+
+    Or you might want to randomly select a 10 percent of your resources,
+    but no more than 15.
+
+    :example:
+
+    .. code-block:: yaml
+
+      - name: random-selection
+        resource: ec2
+        filters:
+          - type: reduce
+            order: randomize
+            limit: 15
+            limit-percent: 10
+
+    """
+    annotate = False
+
+    schema = {
+        'type': 'object',
+        # Doesn't mix well with inherits that extend
+        'additionalProperties': False,
+        'required': ['type'],
+        'properties': {
+            # Doesn't mix well as enum with inherits that extend
+            'type': {'enum': ['reduce']},
+            'group-by': {
+                'oneOf': [
+                    {'type': 'string'},
+                    {
+                        'type': 'object',
+                        'key': {'type': 'string'},
+                        'value_type': {'enum': ['string', 'number', 'date']},
+                        'value_regex': 'string',
+                    },
+                ]
+            },
+            'sort-by': {
+                'oneOf': [
+                    {'type': 'string'},
+                    {
+                        'type': 'object',
+                        'key': {'type': 'string'},
+                        'value_type': {'enum': ['string', 'number', 'date']},
+                        'value_regex': 'string',
+                    },
+                ]
+            },
+            'order': {'enum': ['asc', 'desc', 'reverse', 'randomize']},
+            'null-order': {'enum': ['first', 'last']},
+            'limit': {'type': 'number', 'minimum': 0},
+            'limit-percent': {'type': 'number', 'minimum': 0, 'maximum': 100},
+            'discard': {'type': 'number', 'minimum': 0},
+            'discard-percent': {'type': 'number', 'minimum': 0, 'maximum': 100},
+        },
+    }
+    schema_alias = True
+
+    def __init__(self, data, manager):
+        super(ReduceFilter, self).__init__(data, manager)
+        self.order = self.data.get('order', 'asc')
+        self.group_by = self.get_sort_config('group-by')
+        self.sort_by = self.get_sort_config('sort-by')
+
+    def validate(self):
+        # make sure the regexes compile
+        if 'value_regex' in self.group_by:
+            self._validate_value_regex(self.group_by['value_regex'])
+        if 'value_regex' in self.sort_by:
+            self._validate_value_regex(self.sort_by['value_regex'])
+        return self
+
+    def process(self, resources, event=None):
+        groups = self.group(resources)
+
+        # specified either of the sorting options, so sort
+        if 'sort-by' in self.data or 'order' in self.data:
+            groups = self.sort_groups(groups)
+
+        # now apply any limits to the groups and concatenate
+        return list(filter(None, self.limit(groups)))
+
+    def group(self, resources):
+        groups = {}
+        for r in resources:
+            v = self._value_to_sort(self.group_by, r)
+            vstr = str(v)
+            if vstr not in groups:
+                groups[vstr] = {'sortkey': v, 'resources': []}
+            groups[vstr]['resources'].append(r)
+        return groups
+
+    def get_sort_config(self, key):
+        # allow `foo: bar` but convert to
+        # `foo: {'key': bar}`
+        d = self.data.get(key, {})
+        if isinstance(d, str):
+            d = {'key': d}
+        d['null_sort_value'] = self.null_sort_value(d)
+        return d
+
+    def sort_groups(self, groups):
+        for g in groups:
+            groups[g]['resources'] = self.reorder(
+                groups[g]['resources'],
+                key=lambda r: self._value_to_sort(self.sort_by, r),
+            )
+        return groups
+
+    def _value_to_sort(self, config, r):
+        expr = config.get('key')
+        vtype = config.get('value_type', 'string')
+        vregex = config.get('value_regex')
+        v = None
+
+        try:
+            # extract value based on jmespath
+            if expr:
+                v = self.get_resource_value(expr, r, vregex)
+
+            if v is not None:
+                # now convert to expected type
+                if vtype == 'number':
+                    v = float(v)
+                elif vtype == 'date':
+                    v = parse_date(v)
+                else:
+                    v = str(v)
+        except (AttributeError, ValueError):
+            v = None
+
+        if v is None:
+            v = config.get('null_sort_value')
+        return v
+
+    def null_sort_value(self, config):
+        vtype = config.get('value_type', 'string')
+        placement = self.data.get('null-order', 'last')
+
+        if (placement == 'last' and self.order == 'desc') or (
+            placement != 'last' and self.order != 'desc'
+        ):
+            # return a value that will sort first
+            if vtype == 'number':
+                return float('-inf')
+            elif vtype == 'date':
+                return datetime.datetime.min.replace(tzinfo=tzutc())
+            return ''
+        else:
+            # return a value that will sort last
+            if vtype == 'number':
+                return float('inf')
+            elif vtype == 'date':
+                return datetime.datetime.max.replace(tzinfo=tzutc())
+            return '\uffff'
+
+    def limit(self, groups):
+        results = []
+
+        max = self.data.get('limit', 0)
+        pct = self.data.get('limit-percent', 0)
+        drop = self.data.get('discard', 0)
+        droppct = self.data.get('discard-percent', 0)
+        ordered = list(groups)
+        if 'group-by' in self.data or 'order' in self.data:
+            ordered = self.reorder(ordered, key=lambda r: groups[r]['sortkey'])
+        for g in ordered:
+            # discard X first
+            if droppct > 0:
+                n = int(droppct / 100 * len(groups[g]['resources']))
+                if n > drop:
+                    drop = n
+            if drop > 0:
+                groups[g]['resources'] = groups[g]['resources'][drop:]
+
+            # then limit the remaining
+            count = len(groups[g]['resources'])
+            if pct > 0:
+                count = int(pct / 100 * len(groups[g]['resources']))
+            if max > 0 and max < count:
+                count = max
+            results.extend(groups[g]['resources'][0:count])
+        return results
+
+    def reorder(self, items, key=None):
+        if self.order == 'randomize':
+            return sample(items, k=len(items))
+        elif self.order == 'reverse':
+            return items[::-1]
+        else:
+            return sorted(items, key=key, reverse=(self.order == 'desc'))
diff --git a/c7n/schema.py b/c7n/schema.py
--- a/c7n/schema.py
+++ b/c7n/schema.py
@@ -37,7 +37,14 @@
 from c7n.provider import clouds
 from c7n.resources import load_available
 from c7n.resolver import ValuesFrom
-from c7n.filters.core import ValueFilter, EventFilter, AgeFilter, OPERATORS, VALUE_TYPES
+from c7n.filters.core import (
+    ValueFilter,
+    EventFilter,
+    AgeFilter,
+    ReduceFilter,
+    OPERATORS,
+    VALUE_TYPES,
+)
 from c7n.structure import StructureParser # noqa
 
 
@@ -202,6 +209,7 @@ def generate(resource_types=()):
             'value': ValueFilter.schema,
             'event': EventFilter.schema,
             'age': AgeFilter.schema,
+            'reduce': ReduceFilter.schema,
             # Shortcut form of value filter as k=v
             'valuekv': {
                 'type': 'object',


Test patch:
diff --git a/tests/test_filters.py b/tests/test_filters.py
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1065,5 +1065,342 @@ def test_missing_metrics_with_fillvalue(self):
         )
 
 
+class TestReduceFilter(BaseFilterTest):
+
+    def instances(self):
+        return [
+            dict(InstanceId="A", Group="A", Foo="a", Bar="3", Date="2011/05/06"),
+            dict(InstanceId="B", Group="B", Foo="c", Bar="1", Date="2020/01/01"),
+            dict(InstanceId="C", Group="C", Foo="d", Date="2015-05-25T01:02:03"),
+            dict(InstanceId="D", Group="A", Foo="b", Date="1592870000"),  # 2020-06-22 23:53:20 UTC
+            dict(InstanceId="E", Group="B", Foo="e", Bar="23", Date="invalid"),
+            dict(InstanceId="F", Group="C", Foo="f"),
+        ]
+
+    def test_limit(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "limit": 2,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 2)
+
+    def test_limit_no_number(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "limit": 0,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+
+    def test_limit_negative_number(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "limit": -2,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+
+    def test_limit_percent(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "limit-percent": 50,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 3)
+        self.assertEqual([r['InstanceId'] for r in rs], ['A', 'B', 'C'])
+
+    def test_limit_percent_and_count(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "limit": 2,
+                "limit-percent": 50,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 2)
+        self.assertEqual([r['InstanceId'] for r in rs], ['A', 'B'])
+
+    def test_discard(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "discard": 2,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 4)
+
+    def test_discard_percent(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "discard": 2,
+                "discard-percent": 50,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 3)
+        self.assertEqual([r['InstanceId'] for r in rs], ['D', 'E', 'F'])
+
+    def test_discard_and_limit(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "discard": 2,
+                "limit": 2,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 2)
+        self.assertEqual([r['InstanceId'] for r in rs], ['C', 'D'])
+
+    def test_sort(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "sort-by": "Foo",
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+        self.assertEqual([r['Foo'] for r in rs], ['a', 'b', 'c', 'd', 'e', 'f'])
+
+    def test_sort_desc(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "sort-by": "Foo",
+                "order": "desc",
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+        self.assertEqual([r['Foo'] for r in rs], ['f', 'e', 'd', 'c', 'b', 'a'])
+
+    def test_group_sort(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": "Group",
+                "sort-by": "Foo",
+                "order": "desc",
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+        self.assertEqual([r['InstanceId'] for r in rs], ['F', 'C', 'E', 'B', 'D', 'A'])
+        ['D', 'A', 'E', 'B', 'F', 'C']
+
+    def test_group_sort_limit(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": "Group",
+                "sort-by": "Foo",
+                "order": "desc",
+                "limit": 1,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 3)
+        self.assertEqual([r['InstanceId'] for r in rs], ['F', 'E', 'D'])
+
+    def test_randomize(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "order": "randomize",
+            }
+        )
+        rs1 = f.process(resources)
+        rs2 = f.process(resources)
+        self.assertEqual(len(rs1), len(resources))
+        self.assertEqual(len(rs2), len(resources))
+        self.assertNotEqual(
+            [r['InstanceId'] for r in rs1],
+            [r['InstanceId'] for r in rs2]
+        )
+
+    def test_reverse(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "order": "reverse",
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), len(resources))
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            [r['InstanceId'] for r in resources[::-1]]
+        )
+
+    def test_sort_string(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "sort-by": "Bar",
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['B', 'E', 'A', 'C', 'D', 'F']
+        )
+
+    def test_sort_number(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "sort-by": {
+                    "key": "Bar",
+                    "value_type": "number"
+                }
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['B', 'A', 'E', 'C', 'D', 'F']
+        )
+
+    def test_sort_date(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "sort-by": {
+                    "key": "Date",
+                    "value_type": "date"
+                }
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['A', 'C', 'B', 'D', 'E', 'F']
+        )
+
+    def test_group_string(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": "Bar",
+                "limit": 1,
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(len(rs), 4)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['B', 'E', 'A', 'C']
+        )
+
+    def test_group_number(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": {
+                    "key": "Bar",
+                    "value_type": "number"
+                },
+                "limit": 1
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['B', 'A', 'E', 'C']
+        )
+
+    def test_group_regex_date_asc(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": {
+                    "key": "Date",
+                    "value_type": "date",
+                    "value_regex": "([0-9]{4}-[0-9]{2}-[0-9]{2}).*"
+                },
+                "limit": 1
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['C', 'A']
+        )
+
+    def test_group_regex_date_desc(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": {
+                    "key": "Date",
+                    "value_type": "date",
+                    "value_regex": "([0-9]{4}[/-][0-9]{2}[/-][0-9]{2}).*",
+                },
+                "order": "desc",
+                "limit": 1
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['B', 'C', 'A', 'D']
+        )
+
+    def test_group_regex_date_desc_null_first(self):
+        resources = self.instances()
+        f = filters.factory(
+            {
+                "type": "reduce",
+                "group-by": {
+                    "key": "Date",
+                    "value_type": "date",
+                    "value_regex": "([0-9]{4}[/-][0-9]{2}[/-][0-9]{2}).*",
+                },
+                "order": "desc",
+                "null-order": "first",
+                "limit": 1
+            }
+        )
+        rs = f.process(resources)
+        self.assertEqual(
+            [r['InstanceId'] for r in rs],
+            ['D', 'B', 'C', 'A']
+        )
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_policy.py b/tests/test_policy.py
--- a/tests/test_policy.py
+++ b/tests/test_policy.py
@@ -440,7 +440,7 @@ def test_resource_permissions(self):
                     missing.append("%s.actions.%s" % (k, n))
 
             for n, f in list(v.filter_registry.items()):
-                if n in ("and", "or", "not", "missing"):
+                if n in ("and", "or", "not", "missing", "reduce"):
                     continue
                 p["filters"] = [n]
                 perms = f({}, mgr).get_permissions()
diff --git a/tools/c7n_gcp/tests/test_resource.py b/tools/c7n_gcp/tests/test_resource.py
--- a/tools/c7n_gcp/tests/test_resource.py
+++ b/tools/c7n_gcp/tests/test_resource.py
@@ -22,7 +22,7 @@
 
 
 ALLOWED_NOPERM = set((
-    'or', 'and', 'not', 'value',
+    'or', 'and', 'not', 'value', 'reduce',
     'offhour', 'onhour', 'marked-for-op',
     'event', 'webhook'))