Repository: cloud-custodian/cloud-custodian

Issue:
"{now}" throws an error with c7n 0.9.26
I updated c7n, c7n-org and c7n-mailer and found an issue with the use of "{now}" as a date when it comes to a tag value for AWS resources (EC2, RDS, etc).

I get 
error:Object of type FormatDate is not JSON serializable
when designating a date as "{now}". Here is an example from one of the policies that fail with this message
   actions:
      - type: tag
        key: c7n_health_check
        value: "{now}"

From my requirements.txt for the CodeBuild pipeline
c7n==0.9.26
c7n-org==0.6.25
c7n-mailer==0.6.25

Is this related to https://github.com/cloud-custodian/cloud-custodian/issues/7916?


Discussion:

Resolving patch:
diff --git a/c7n/policy.py b/c7n/policy.py
--- a/c7n/policy.py
+++ b/c7n/policy.py
@@ -1262,7 +1262,12 @@ def get_variables(self, variables=None):
             'op': '{op}',
             'action_date': '{action_date}',
             # tag action pyformat-date handling
-            'now': utils.FormatDate(datetime.utcnow()),
+            # defer expansion until runtime for serverless modes
+            'now': (
+                utils.DeferredFormatString('now')
+                if isinstance(self.get_execution_mode(), ServerlessExecutionMode)
+                else utils.FormatDate(datetime.utcnow())
+            ),
             # account increase limit action
             'service': '{service}',
             # s3 set logging action :-( see if we can revisit this one.
diff --git a/c7n/utils.py b/c7n/utils.py
--- a/c7n/utils.py
+++ b/c7n/utils.py
@@ -1,6 +1,7 @@
 # Copyright The Cloud Custodian Authors.
 # SPDX-License-Identifier: Apache-2.0
 import copy
+from collections import UserString
 from datetime import datetime, timedelta
 from dateutil.tz import tzutc
 import json
@@ -680,6 +681,16 @@ def get_proxy_url(url):
     return None
 
 
+class DeferredFormatString(UserString):
+    """A string that returns itself when formatted
+
+    Let any format spec pass through. This lets us selectively defer
+    expansion of runtime variables without losing format spec details.
+    """
+    def __format__(self, format_spec):
+        return "".join(("{", self.data, f":{format_spec}" if format_spec else "", "}"))
+
+
 class FormatDate:
     """a datetime wrapper with extended pyformat syntax"""
 
diff --git a/c7n/varfmt.py b/c7n/varfmt.py
--- a/c7n/varfmt.py
+++ b/c7n/varfmt.py
@@ -1,4 +1,5 @@
 from string import Formatter
+from c7n.utils import DeferredFormatString
 
 
 class VarFormat(Formatter):
@@ -82,7 +83,7 @@ def _vformat(
             obj, is_literal, format_spec = result[0]
             if is_literal:
                 return obj, auto_arg_index
-            if format_spec:
+            if format_spec or isinstance(obj, DeferredFormatString):
                 return self.format_field(obj, format_spec), auto_arg_index
             else:
                 return obj, auto_arg_index


Test patch:
diff --git a/tests/test_mu.py b/tests/test_mu.py
--- a/tests/test_mu.py
+++ b/tests/test_mu.py
@@ -168,6 +168,21 @@ def test_published_lambda_architecture(self):
             result = mgr.publish(pl)
             self.assertEqual(result["Architectures"], ["arm64"])
 
+    def test_deferred_interpolation(self):
+        p = self.load_policy({
+            'name': 'ec2-foo-bar',
+            'resource': 'aws.ec2',
+            'mode': {
+                'type': 'cloudtrail',
+                'role': 'arn:aws:iam::644160558196:role/custodian-mu',
+                'events': ['RunInstances']},
+            'actions': [{
+                'type': 'tag', 'key': 'LastMatch', 'value': '{now}'
+            }]})
+        p.expand_variables(p.get_variables())
+        pl = PolicyLambda(p)
+        pl.get_archive()
+
     def test_updated_lambda_architecture(self):
         session_factory = self.replay_flight_data("test_updated_lambda_architecture")
         lambda_client = session_factory().client("lambda")
diff --git a/tests/test_policy.py b/tests/test_policy.py
--- a/tests/test_policy.py
+++ b/tests/test_policy.py
@@ -1070,8 +1070,6 @@ def test_policy_variable_interpolation(self):
                 'member-role': 'arn:aws:iam::{account_id}:role/BarFoo',
                 'role': 'FooBar'},
             'actions': [
-                {'type': 'tag',
-                 'value': 'bad monkey {account_id} {region} {now:+2d%Y-%m-%d}'},
                 {'type': 'notify',
                  'to': ['me@example.com'],
                  'transport': {
@@ -1081,16 +1079,67 @@ def test_policy_variable_interpolation(self):
                  'subject': "S3 - Cross-Account -[custodian {{ account }} - {{ region }}]"},
             ]}, config={'account_id': '12312311', 'region': 'zanzibar'})
 
-        ivalue = 'bad monkey 12312311 zanzibar %s' % (
-            (datetime.utcnow() + timedelta(2)).strftime('%Y-%m-%d'))
         p.expand_variables(p.get_variables())
-        self.assertEqual(p.data['actions'][0]['value'], ivalue)
         self.assertEqual(
-            p.data['actions'][1]['subject'],
+            p.data['actions'][0]['subject'],
             "S3 - Cross-Account -[custodian {{ account }} - {{ region }}]")
         self.assertEqual(p.data['mode']['role'], 'arn:aws:iam::12312311:role/FooBar')
         self.assertEqual(p.data['mode']['member-role'], 'arn:aws:iam::{account_id}:role/BarFoo')
-        self.assertEqual(p.resource_manager.actions[0].data['value'], ivalue)
+
+    def test_now_interpolation(self):
+        """Test interpolation of the {now} placeholder
+
+        - Only interpolate the value at runtime, not during provisioning
+        - When deferring interpolation, pass through custom format specifiers
+        """
+
+        pull_mode_policy = self.load_policy({
+            'name': 'compute',
+            'resource': 'aws.ec2',
+            'actions': [
+                {'type': 'tag',
+                 'value': 'bad monkey {account_id} {region} {now:+2d%Y-%m-%d}'},
+                {'type': 'tag',
+                 'key': 'escaped_braces',
+                 'value': '{{now}}'},
+            ]}, config={'account_id': '12312311', 'region': 'zanzibar'})
+        lambda_mode_policy = self.load_policy({
+            **pull_mode_policy.data,
+            **{
+                'mode': {
+                    'type': 'config-rule',
+                    'role': 'FooBar'},
+            }
+        }, config=pull_mode_policy.ctx.options)
+
+        provision_time_value = 'bad monkey 12312311 zanzibar {now:+2d%Y-%m-%d}'
+        run_time_value = 'bad monkey 12312311 zanzibar %s' % (
+            (datetime.utcnow() + timedelta(2)).strftime('%Y-%m-%d'))
+
+        pull_mode_policy.expand_variables(pull_mode_policy.get_variables())
+        self.assertEqual(
+            pull_mode_policy.data['actions'][0]['value'],
+            run_time_value
+        )
+        self.assertEqual(
+            pull_mode_policy.resource_manager.actions[0].data['value'],
+            run_time_value
+        )
+
+        lambda_mode_policy.expand_variables(lambda_mode_policy.get_variables())
+        self.assertEqual(
+            lambda_mode_policy.data['actions'][0]['value'],
+            provision_time_value
+        )
+        self.assertEqual(
+            lambda_mode_policy.resource_manager.actions[0].data['value'],
+            provision_time_value
+        )
+        # Validate historical use of {{now}} to defer interpolation
+        self.assertEqual(
+            lambda_mode_policy.resource_manager.actions[1].data['value'],
+            '{now}'
+        )
 
     def test_child_resource_trail_validation(self):
         self.assertRaises(