1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3"""
4Custodian support for diffing and patching across multiple versions
5of a resource.
6"""
7
8from dateutil.parser import parse as parse_date
9from dateutil.tz import tzlocal, tzutc
10
11from c7n.exceptions import PolicyValidationError, ClientError
12from c7n.filters import Filter
13from c7n.manager import resources
14from c7n.utils import local_session, type_schema
15
16try:
17 import jsonpatch
18 HAVE_JSONPATH = True
19except ImportError:
20 HAVE_JSONPATH = False
21
22
23ErrNotFound = "ResourceNotDiscoveredException"
24
25UTC = tzutc()
26
27
28class Diff(Filter):
29 """Compute the diff from the current resource to a previous version.
30
31 A resource matches the filter if a diff exists between the current
32 resource and the selected revision.
33
34 Utilizes config as a resource revision database.
35
36 Revisions can be selected by date, against the previous version, and
37 against a locked version (requires use of is-locked filter).
38 """
39
40 schema = type_schema(
41 'diff',
42 selector={'enum': ['previous', 'date', 'locked']},
43 # For date selectors allow value specification
44 selector_value={'type': 'string'})
45
46 permissions = ('config:GetResourceConfigHistory',)
47
48 selector_value = mode = parser = resource_shape = None
49
50 def validate(self):
51 if 'selector' in self.data and self.data['selector'] == 'date':
52 if 'selector_value' not in self.data:
53 raise PolicyValidationError(
54 "Date version selector requires specification of date on %s" % (
55 self.manager.data))
56 try:
57 parse_date(self.data['selector_value'])
58 except ValueError:
59 raise PolicyValidationError(
60 "Invalid date for selector_value on %s" % (self.manager.data))
61
62 elif 'selector' in self.data and self.data['selector'] == 'locked':
63 idx = self.manager.data['filters'].index(self.data)
64 found = False
65 for n in self.manager.data['filters'][:idx]:
66 if isinstance(n, dict) and n.get('type', '') == 'locked':
67 found = True
68 if isinstance(n, str) and n == 'locked':
69 found = True
70 if not found:
71 raise PolicyValidationError(
72 "locked selector needs previous use of is-locked filter on %s" % (
73 self.manager.data))
74 return self
75
76 def process(self, resources, event=None):
77 session = local_session(self.manager.session_factory)
78 config = session.client('config')
79 self.model = self.manager.get_model()
80
81 results = []
82 for r in resources:
83 revisions = self.get_revisions(config, r)
84 r['c7n:previous-revision'] = rev = self.select_revision(revisions)
85 if not rev:
86 continue
87 delta = self.diff(rev['resource'], r)
88 if delta:
89 r['c7n:diff'] = delta
90 results.append(r)
91 return results
92
93 def get_revisions(self, config, resource):
94 params = dict(
95 resourceType=self.model.config_type,
96 resourceId=resource[self.model.id])
97 params.update(self.get_selector_params(resource))
98 try:
99 revisions = config.get_resource_config_history(
100 **params)['configurationItems']
101 except ClientError as e:
102 if e.response['Error']['Code'] == 'ResourceNotDiscoveredException':
103 return []
104 if e.response['Error']['Code'] != ErrNotFound:
105 self.log.debug(
106 "config - resource %s:%s not found" % (
107 self.model.config_type, resource[self.model.id]))
108 revisions = []
109 raise
110 return revisions
111
112 def get_selector_params(self, resource):
113 params = {}
114 selector = self.data.get('selector', 'previous')
115 if selector == 'date':
116 if not self.selector_value:
117 self.selector_value = parse_date(
118 self.data.get('selector_value'))
119 params['laterTime'] = self.selector_value
120 params['limit'] = 3
121 elif selector == 'previous':
122 params['limit'] = 2
123 elif selector == 'locked':
124 params['laterTime'] = resource.get('c7n:locked_date')
125 params['limit'] = 2
126 return params
127
128 def select_revision(self, revisions):
129 for rev in revisions:
130 # convert unix timestamp to utc to be normalized with other dates
131 if rev['configurationItemCaptureTime'].tzinfo and \
132 isinstance(rev['configurationItemCaptureTime'].tzinfo, tzlocal):
133 rev['configurationItemCaptureTime'] = rev[
134 'configurationItemCaptureTime'].astimezone(UTC)
135 return {
136 'date': rev['configurationItemCaptureTime'],
137 'version_id': rev['configurationStateId'],
138 'events': rev['relatedEvents'],
139 'resource': self.transform_revision(rev)}
140
141 def transform_revision(self, revision):
142 """make config revision look like describe output."""
143 config = self.manager.get_source('config')
144 return config.load_resource(revision)
145
146 def diff(self, source, target):
147 raise NotImplementedError("Subclass responsibility")
148
149
150class JsonDiff(Diff):
151
152 schema = type_schema(
153 'json-diff',
154 selector={'enum': ['previous', 'date', 'locked']},
155 # For date selectors allow value specification
156 selector_value={'type': 'string'})
157
158 def diff(self, source, target):
159 source, target = (
160 self.sanitize_revision(source), self.sanitize_revision(target))
161 patch = jsonpatch.JsonPatch.from_diff(source, target)
162 return list(patch)
163
164 def sanitize_revision(self, rev):
165 sanitized = dict(rev)
166 for k in [k for k in sanitized if 'c7n' in k]:
167 sanitized.pop(k)
168 return sanitized
169
170 @classmethod
171 def register_resources(klass, registry, resource_class):
172 """ meta model subscriber on resource registration.
173
174 We watch for new resource types being registered and if they
175 support aws config, automatically register the jsondiff filter.
176 """
177 if resource_class.resource_type.config_type is None:
178 return
179 resource_class.filter_registry.register('json-diff', klass)
180
181
182if HAVE_JSONPATH:
183 resources.subscribe(JsonDiff.register_resources)