tgwrap 0.9.3__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgwrap/analyze.py CHANGED
@@ -185,7 +185,7 @@ def has_update_action(resource):
185
185
 
186
186
  def is_resource_match_any(resource_address, pattern_list):
187
187
  for pattern in pattern_list:
188
- pattern = re.sub(r"\[(.+?)\]", "[[]\g<1>[]]", pattern)
188
+ pattern = re.sub(r"\[(.+?)\]", "[[]\\g<1>[]]", pattern)
189
189
  if fnmatch.fnmatch(resource_address, pattern):
190
190
  return True
191
191
  return False
@@ -193,7 +193,7 @@ def is_resource_match_any(resource_address, pattern_list):
193
193
 
194
194
  def get_matching_dd_config(resource_address, dd_config):
195
195
  for pattern, config in dd_config.items():
196
- pattern = re.sub(r"\[(.+?)\]", "[[]\g<1>[]]", pattern)
196
+ pattern = re.sub(r"\[(.+?)\]", "[[]\\g<1>[]]", pattern)
197
197
  if fnmatch.fnmatch(resource_address, pattern):
198
198
  return True, config
199
199
  return False, None
tgwrap/cli.py CHANGED
@@ -42,7 +42,7 @@ def check_latest_version(verbose=False):
42
42
  # this happens when your local version is ahead of the pypi version,
43
43
  # which happens only in development
44
44
  pass
45
- except Exception:
45
+ except :
46
46
  echo('Could not determine package version, continue nevertheless.')
47
47
  pass
48
48
 
@@ -237,12 +237,12 @@ def run(command, verbose, debug, dry_run, no_lock, update, upgrade,
237
237
  )
238
238
  @click.option('--include-dir', '-I',
239
239
  multiple=True, default=[],
240
- help='A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
240
+ help=r'A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
241
241
  show_default=True
242
242
  )
243
243
  @click.option('--exclude-dir', '-E',
244
244
  multiple=True, default=[],
245
- help='A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
245
+ help=r'A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
246
246
  show_default=True,
247
247
  )
248
248
  @click.argument('terragrunt-args', nargs=-1, type=click.UNPROCESSED)
@@ -390,12 +390,12 @@ def run_import(address, id, verbose, dry_run, working_dir, no_lock, terragrunt_a
390
390
  )
391
391
  @click.option('--include-dir', '-I',
392
392
  multiple=True, default=[],
393
- help='A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
393
+ help=r'A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
394
394
  show_default=True
395
395
  )
396
396
  @click.option('--exclude-dir', '-E',
397
397
  multiple=True, default=[],
398
- help='A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
398
+ help=r'A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
399
399
  show_default=True,
400
400
  )
401
401
  @click.option('--planfile-dir', '-P', default='.terragrunt-cache/current',
@@ -752,12 +752,12 @@ def check_deployments(manifest_file, verbose, working_dir, out):
752
752
  )
753
753
  @click.option('--include-dir', '-I',
754
754
  multiple=True, default=[],
755
- help='A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
755
+ help=r'A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
756
756
  show_default=True
757
757
  )
758
758
  @click.option('--exclude-dir', '-E',
759
759
  multiple=True, default=[],
760
- help='A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
760
+ help=r'A glob of a directory that needs to be excluded, this option can be used multiple times. For example: -E "integrations/\*/\*"',
761
761
  show_default=True,
762
762
  )
763
763
  @click.option('--working-dir', '-w', default=None,
@@ -839,6 +839,66 @@ def change_log(changelog_file, verbose, working_dir, include_nbr_of_releases):
839
839
  include_nbr_of_releases=include_nbr_of_releases,
840
840
  )
841
841
 
842
+ @main.command(
843
+ name="inspect",
844
+ context_settings=dict(
845
+ ignore_unknown_options=True,
846
+ ),
847
+ )
848
+ @click.option('--domain', '-d',
849
+ help='Domain name used in naming the objects',
850
+ required=True,
851
+ )
852
+ @click.option('--substack', '-S',
853
+ help='Identifier that is needed to select the objects',
854
+ default=None,
855
+ )
856
+ @click.option('--stage', '-s',
857
+ help='Stage (environment) to verify',
858
+ required=True,
859
+ )
860
+ @click.option('--azure-subscription-id', '-a',
861
+ help='Azure subscription id',
862
+ required=True,
863
+ )
864
+ @click.option('--config-file', '-c',
865
+ help='Config file specifying the verifications',
866
+ required=True,
867
+ type=click.Path(),
868
+ )
869
+ @click.option('--out', '-o', is_flag=True, default=False,
870
+ help='Show output as json',
871
+ show_default=True
872
+ )
873
+ @click.option('--data-collection-endpoint', '-D', default=None,
874
+ help='Optional URI of an (Azure) data collection endpoint, to which the inspection results will be sent',
875
+ envvar='TGWRAP_INSPECT_DATA_COLLECTION_ENDPOINT',
876
+ show_default=True,
877
+ )
878
+ @click.option('--verbose', '-v', is_flag=True, default=False,
879
+ help='Verbose printing',
880
+ show_default=True
881
+ )
882
+ @click.version_option(version=__version__)
883
+ def inspect(domain, substack, stage, azure_subscription_id, config_file, out,
884
+ data_collection_endpoint, verbose):
885
+ """ Inspect the status of an (Azure) environment """
886
+
887
+ check_latest_version(verbose)
888
+
889
+ tgwrap = TgWrap(verbose=verbose)
890
+ exit = tgwrap.inspect(
891
+ domain=domain,
892
+ substack=substack,
893
+ stage=stage,
894
+ azure_subscription_id=azure_subscription_id,
895
+ out=out,
896
+ data_collection_endpoint=data_collection_endpoint,
897
+ config_file=config_file,
898
+ )
899
+
900
+ sys.exit(exit)
901
+
842
902
  # this is needed for the vscode debugger to work
843
903
  if __name__ == '__main__':
844
904
  main()
@@ -0,0 +1,58 @@
1
+ ---
2
+ entra_id_group:
3
+ # note that there must be single quotes around the {name}, double quotes are not working!
4
+ url: "https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '{name}'"
5
+ properties: {}
6
+ subscription:
7
+ url: https://management.azure.com/subscriptions/{subscription_id}?api-version=2024-03-01
8
+ properties:
9
+ state: Enabled
10
+ resource_group:
11
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{name}?api-version=2024-03-01
12
+ properties:
13
+ properties.provisioningState: Succeeded
14
+ location: "{{location_code}}"
15
+ key_vault:
16
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{name}?api-version=2019-09-01
17
+ properties:
18
+ properties.provisioningState: Succeeded
19
+ location: "{{location_code}}"
20
+ application_insights:
21
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Insights/components/{name}?api-version=2014-04-01
22
+ properties:
23
+ properties.provisioningState: Succeeded
24
+ location: "{{location_code}}"
25
+ log_analytics_workspace:
26
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.OperationalInsights/workspaces/{name}?api-version=2021-06-01
27
+ properties:
28
+ properties.provisioningState: Succeeded
29
+ location: "{{location_code}}"
30
+ storage_account:
31
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Storage/storageAccounts/{name}?api-version=2021-08-01
32
+ properties:
33
+ properties.provisioningState: Succeeded
34
+ kind: StorageV2
35
+ location: "{{location_code}}"
36
+ databricks_access_connector:
37
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Databricks/accessConnectors/{name}?api-version=2024-05-01
38
+ properties:
39
+ properties.provisioningState: Succeeded
40
+ location: "{{location_full}}"
41
+ databricks_workspace:
42
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Databricks/workspaces/{name}?api-version=2024-05-01
43
+ properties:
44
+ properties.provisioningState: Succeeded
45
+ location: "{{location_code}}"
46
+ data_factory:
47
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.DataFactory/factories/{name}?api-version=2018-06-01
48
+ properties:
49
+ properties.provisioningState: Succeeded
50
+ properties.repoConfiguration.repositoryName: datafactory
51
+ properties.repoConfiguration.type: FactoryVSTSConfiguration
52
+ location: "{{location_code}}"
53
+ function_app:
54
+ url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Web/sites/{name}?api-version=2021-01-15
55
+ properties:
56
+ properties.state: Running
57
+ properties.enabled: true
58
+ location: "{{location_full}}"
tgwrap/inspector.py ADDED
@@ -0,0 +1,438 @@
1
+ """Verify a given Azure environment based on some verification config file"""
2
+
3
+ import os
4
+ import yaml
5
+ import requests
6
+ import json
7
+ import re
8
+
9
+ from typing import Tuple, Dict, List
10
+ from enum import Enum
11
+ from jinja2 import Template
12
+
13
+ from azure.identity import DefaultAzureCredential
14
+ from azure.core.exceptions import ClientAuthenticationError
15
+ from azure.mgmt.authorization import AuthorizationManagementClient
16
+
17
+ from .printer import Printer
18
+
19
+ class ResourceInspectionStatus(Enum):
20
+ OK = "Successfully inspected the resource"
21
+ NOK = "Resource does not have the right status"
22
+ NEX = "Resource does not exist"
23
+ NS = "Resource type is not supported"
24
+
25
+ class RoleAssignmentInspectionStatus(Enum):
26
+ OK = "Successfully inspected the role assignments"
27
+ NC = "Role assignments not checked"
28
+ NOK = "(Some) role assignments missing"
29
+
30
+ class AzureInspector():
31
+ _printer = None
32
+ _subscription_id = None
33
+ _domain = None
34
+ _substack = None
35
+ _stage = None
36
+ _config = {}
37
+ _credential = None
38
+ _client = None
39
+ _graph_token = None
40
+ _mngt_token = None
41
+ _role_definitions = {}
42
+ _result = {}
43
+
44
+ @property
45
+ def printer(self):
46
+ return self._printer
47
+
48
+ @property
49
+ def subscription_id(self):
50
+ return self._subscription_id
51
+
52
+ @property
53
+ def domain(self):
54
+ return self._domain
55
+
56
+ @property
57
+ def substack(self):
58
+ return self._substack
59
+
60
+ @property
61
+ def stage(self):
62
+ return self._stage
63
+
64
+ @property
65
+ def config(self):
66
+ return self._config
67
+
68
+ @property
69
+ def credential(self):
70
+ return self._credential
71
+
72
+ @property
73
+ def client(self):
74
+ return self._client
75
+
76
+ @property
77
+ def graph_token(self):
78
+ return self._graph_token
79
+
80
+ @property
81
+ def mngt_token(self):
82
+ return self._mngt_token
83
+
84
+ @property
85
+ def role_definitions(self):
86
+ return self._role_definitions
87
+
88
+ @property
89
+ def result(self):
90
+ return self._result
91
+
92
+ @result.setter
93
+ def result(self, value):
94
+ self._result = value
95
+
96
+ def __init__(self, subscription_id:str, domain:str, substack:str, stage:str, config_file:str, verbose:bool, managed_identity_client_id:str = None):
97
+ self._printer = Printer(verbose)
98
+ self._subscription_id = subscription_id
99
+ self._domain = domain
100
+ self._substack = substack
101
+ self._stage = stage
102
+
103
+ # load the config file
104
+ self._config = self._load_config(config_file)
105
+
106
+ # get a credential
107
+ self._credential = DefaultAzureCredential(
108
+ # if you are using a user-assigned identity, the client id must be specified here!
109
+ managed_identity_client_id = managed_identity_client_id,
110
+ )
111
+
112
+ self._client = AuthorizationManagementClient(
113
+ credential=self.credential,
114
+ subscription_id=self.subscription_id,
115
+ )
116
+
117
+ # Retrieve all role definitions
118
+ self.printer.verbose('Retrieve all role definitions')
119
+ role_definitions_iter = self.client.role_definitions.list(
120
+ scope=f'/subscriptions/{self.subscription_id}'
121
+ )
122
+ for rd in role_definitions_iter:
123
+ self._role_definitions[rd.role_name.lower()] = rd.name
124
+
125
+ def _get_value_from_dict(self, data:str, property:str) -> str:
126
+ """Get a value from a dict with a string that indicates the hierarchy with a dot"""
127
+
128
+ keys = property.split('.')
129
+ value = data
130
+ for key in keys:
131
+ if value:
132
+ value = value.get(key)
133
+
134
+ return value
135
+
136
+ def _load_config(self, config_file:str) -> Dict:
137
+ with open(config_file, 'r') as file:
138
+ try:
139
+ data = yaml.safe_load(file)
140
+ return data
141
+ except yaml.YAMLError as exc:
142
+ self.printer.error(f"Error loading YAML file: {exc}")
143
+ return None
144
+
145
+ def _load_yaml_template(self, template_file:str, inputs:Dict) -> Dict:
146
+ with open(template_file, 'r') as file:
147
+ try:
148
+ template_content = file.read()
149
+ template = Template(template_content)
150
+ rendered_yaml = template.render(inputs)
151
+
152
+ data = yaml.safe_load(rendered_yaml)
153
+ return data
154
+ except yaml.YAMLError as exc:
155
+ self.printer.error(f"Error loading YAML template file: {exc}")
156
+ return None
157
+
158
+ def _invoke_api(self, url:str, token:str, method:str='get', data:Dict=None) -> Dict:
159
+ """Invoke the Azure API"""
160
+
161
+ self.printer.verbose(f'Invoke {method.upper()} on {url}')
162
+ headers = {
163
+ 'Authorization': f'Bearer {token}',
164
+ 'Content-Type': 'application/json'
165
+ }
166
+ resp = None
167
+ try:
168
+ if method.lower() == 'get':
169
+ resp = requests.get(url, headers=headers)
170
+ elif method.lower() == 'post':
171
+ resp = requests.post(url, headers=headers, json=data)
172
+ elif method.lower() == 'delete':
173
+ resp = requests.delete(url, headers=headers)
174
+ else:
175
+ raise ValueError(f'Method {method} not recognised')
176
+
177
+ resp.raise_for_status()
178
+ return resp.json()
179
+ except requests.exceptions.HTTPError as e:
180
+ self.printer.verbose(f'Error occurred:\n{e.response.text}')
181
+ return None
182
+
183
+ def inspect(self):
184
+ try:
185
+ self._graph_token = self.credential.get_token('https://graph.microsoft.com/.default').token
186
+ self._mngt_token = self.credential.get_token('https://management.azure.com/.default').token
187
+ except ClientAuthenticationError as e:
188
+ self.printer.error(
189
+ f'Could not retrieve an azure token, are you logged in?',
190
+ print_line_before=True,
191
+ print_line_after=True,
192
+ )
193
+ raise (e)
194
+
195
+ self.result = {}
196
+
197
+ try:
198
+ # read the config
199
+ location_code = self.config['location'].get('code', 'westeurope')
200
+ location_full = self.config['location'].get('full', 'West Europe')
201
+ resources = self.config.get('resources', [])
202
+ groups = self.config.get('entra_id_groups', {})
203
+
204
+ # read the map that determines how to test a particular resource type
205
+ resource_types = self._load_yaml_template(
206
+ template_file=os.path.join(os.path.dirname(__file__), 'inspector-resources-template.yml'),
207
+ inputs={
208
+ 'location_code': location_code,
209
+ 'location_full': location_full,
210
+ },
211
+ )
212
+
213
+ # first check the groups, this will also enrich the map with the IDs of the actual groups
214
+ # build a list
215
+ groups_to_verify = []
216
+ for role, name in groups.items():
217
+ groups_to_verify.append({
218
+ 'identifier': name.format(
219
+ domain=self.domain,
220
+ substack=self.substack,
221
+ stage=self.stage,
222
+ ),
223
+ 'type': 'entra_id_group',
224
+ 'role': role,
225
+ })
226
+ # and verify
227
+ groups = self.inspect_resources(
228
+ resources=groups_to_verify,
229
+ resource_types=resource_types,
230
+ groups=groups,
231
+ )
232
+
233
+ # now inspect the resources
234
+ self.inspect_resources(
235
+ resources=resources,
236
+ resource_types=resource_types,
237
+ groups=groups,
238
+ )
239
+
240
+ return self.result
241
+ finally:
242
+ # ensure new messages are printed on a new line, as the progress indicator does not create a new line
243
+ self.printer.normal('')
244
+
245
+ def inspect_resources(self, resources:List, resource_types:Dict, groups:Dict) -> Dict:
246
+ for resource in resources:
247
+ self.printer.progress_indicator()
248
+
249
+ # get some identifiers from the config and replace with real values
250
+ identifier = resource['identifier'].format(
251
+ subscription_id=self.subscription_id,
252
+ domain=self.domain,
253
+ substack=self.substack,
254
+ stage=self.stage,
255
+ )
256
+ # it might be that we have an alternative id, as sometimes these are shortened because of length restrictions
257
+ alternative_ids = []
258
+ for id in resource.get('alternative_ids', []):
259
+ alternative_ids.append(
260
+ id.format(
261
+ subscription_id=self.subscription_id,
262
+ domain=self.domain,
263
+ substack=self.substack,
264
+ stage=self.stage,
265
+ )
266
+ )
267
+
268
+ resource_group = resource.get('resource_group', '').format(
269
+ domain=self.domain,
270
+ substack=self.substack,
271
+ stage=self.stage,
272
+ )
273
+
274
+ type = resource['type']
275
+ self.result[identifier] = {
276
+ "type": type,
277
+ }
278
+ this_result = self.result[identifier]
279
+
280
+ self.printer.verbose(f'Inspect {identifier} ({resource_group})')
281
+
282
+ # now we can start inspecting these
283
+ if type in resource_types:
284
+ resource_type = resource_types[type]
285
+ url = resource_type['url'].format(
286
+ subscription_id=self.subscription_id,
287
+ resource_group=resource_group,
288
+ name=identifier,
289
+ )
290
+
291
+ # which token to use?
292
+ graph_api = False
293
+ if 'graph.microsoft.com' in url:
294
+ graph_api = True
295
+ token = self.graph_token
296
+ elif 'management.azure.com' in url:
297
+ token = self.mngt_token
298
+ else:
299
+ self.printer.error(f'Do not have token for url: {url}')
300
+ break
301
+
302
+ resp = self._invoke_api(url=url, token=token)
303
+
304
+ # if we don't get anything back, try alternative ids (if we have some)
305
+ if (not resp or (graph_api and len(resp.get('value', [])) == 0)):
306
+ for id in alternative_ids:
307
+ resp = self._invoke_api(url=url, token=token)
308
+ url = resource_type['url'].format(
309
+ subscription_id=self.subscription_id,
310
+ resource_group=resource_group,
311
+ name=id,
312
+ )
313
+ resp = self._invoke_api(url=url, token=token)
314
+
315
+ # now check if we have something, we stop at first hit
316
+ if (not graph_api and resp) or (graph_api and len(resp.get('value', [])) > 0):
317
+ identifier = id
318
+ break
319
+
320
+ if not resp or (graph_api and len(resp.get('value', [])) == 0):
321
+ status = ResourceInspectionStatus.NEX.name
322
+ if resource_group:
323
+ msg = f"Resource {identifier} (in {resource_group}) of type {type} not found"
324
+ else:
325
+ msg = f"Resource {identifier} of type {type} not found"
326
+ ra_status = RoleAssignmentInspectionStatus.NC.name
327
+ ra_msg = ''
328
+ else:
329
+ self.printer.verbose(json.dumps(resp, indent=2))
330
+
331
+ status = ResourceInspectionStatus.OK.name # we're innocent until proven otherwise
332
+ msg = ""
333
+ for property, expected_value in resource_type['properties'].items():
334
+ property_value = self._get_value_from_dict(data=resp, property=property)
335
+ if isinstance(property_value, str) and not re.match(expected_value, property_value):
336
+ status = ResourceInspectionStatus.NOK.name
337
+ msg = msg + f"Property {property} has value {property_value}, expected regex {expected_value}"
338
+ elif isinstance(property_value, bool) and not property_value:
339
+ status = ResourceInspectionStatus.NOK.name
340
+ msg = msg + f"Property {property} has value {property_value}, expected boolean {expected_value}"
341
+
342
+ # set the default status that we haven't check the role assignments
343
+ ra_status = RoleAssignmentInspectionStatus.NC.name
344
+ ra_msg = 'Role assignments not checked'
345
+
346
+ # if this is an entra id group, we store the object id
347
+ if type == 'entra_id_group':
348
+ role = resource['role']
349
+ id = resp['value'][0]['id']
350
+ groups[role] = {
351
+ 'id': id,
352
+ 'name': identifier,
353
+ }
354
+ elif 'role_assignments' in resource:
355
+
356
+ ra_status, ra_msg = self.check_role_assignments(
357
+ resource=resource,
358
+ groups=groups,
359
+ url=url,
360
+ )
361
+
362
+ if not msg:
363
+ if resource_group:
364
+ msg = f"Resource {identifier} (in {resource_group}) of type {type} OK"
365
+ else:
366
+ msg = f"Resource {identifier} of type {type} OK"
367
+ if not ra_msg and ra_status == RoleAssignmentInspectionStatus.OK.name:
368
+ ra_msg = f"Role Assignments for {identifier} (in {resource_group}) of type {type} are OK"
369
+
370
+ this_result["inspect_status_code"] = status
371
+ this_result["inspect_status"] = ResourceInspectionStatus[status].value
372
+ this_result["inspect_message"] = msg
373
+
374
+ if not type == 'entra_id_group':
375
+ this_result["rbac_assignment_status_code"] = ra_status
376
+ this_result["rbac_assignment_status"] = RoleAssignmentInspectionStatus[ra_status].value
377
+ if ra_msg:
378
+ this_result["rbac_assignment_message"] = ra_msg
379
+ else:
380
+ self.printer.error(f'\nResource type {type} is not configured in tgwrap!')
381
+ this_result["inspect_status_code"] = ResourceInspectionStatus.NS.name
382
+ this_result["inspect_status"] = ResourceInspectionStatus.NS.value
383
+ this_result["inspect_message"] = f'Resource type {type} is not supported'
384
+
385
+ # the groups dict is updated with the IDs of the actual groups
386
+ return groups
387
+
388
+ def check_role_assignments(self, resource:Dict, groups:Dict, url:str):
389
+
390
+ status = RoleAssignmentInspectionStatus.NC.name
391
+ msg = ''
392
+
393
+ # extract the scope from the url
394
+ base_url=url.split('?')[0].rstrip('/')
395
+ scope = base_url.replace('https://management.azure.com', '')
396
+ self.printer.verbose('\nGet permissions over scope: ', scope)
397
+
398
+ # first get the role assignments for each (unique) principals
399
+ principals = set()
400
+ for ra in resource['role_assignments']:
401
+ principals.update(ra.keys())
402
+
403
+ principals = list(principals)
404
+ principal_assignments = {} # here we collect all assignments for this scope per principal
405
+ for p in principals:
406
+ principal_assignments[p] = []
407
+
408
+ if not p in groups:
409
+ self.printer.error(f'\nCannot find the group {p} in groups: {groups}')
410
+ continue
411
+ elif not 'id' in groups[p]:
412
+ self.printer.error(f'\nCannot find the id for group {p}: {groups[p]}')
413
+ continue
414
+
415
+ principal_id = groups[p]['id']
416
+ assignments = self.client.role_assignments.list_for_scope(
417
+ scope=scope,
418
+ filter=f"principalId eq '{principal_id}'",
419
+ )
420
+
421
+ # get the IDs of all roles assigned for the given scope
422
+ for a in assignments:
423
+ principal_assignments[p].append(a.role_definition_id.split('/')[-1]) # we only want the guid of the role definition
424
+
425
+ # assume the role assignments will be fine
426
+ status = RoleAssignmentInspectionStatus.OK.name
427
+ for role in resource['role_assignments']:
428
+ principal = list(role.keys())[0]
429
+ role = list(role.values())[0]
430
+
431
+ if role.lower() not in self.role_definitions:
432
+ raise ValueError(f"Role: '{role}' is not found.")
433
+
434
+ if (self.role_definitions[role.lower()] not in principal_assignments[principal]):
435
+ status = RoleAssignmentInspectionStatus.NOK.name
436
+ msg = msg + f'Principal {principal} has NOT role {role} assigned; '
437
+
438
+ return status, msg
tgwrap/main.py CHANGED
@@ -25,6 +25,7 @@ import yaml
25
25
  import threading
26
26
  import queue
27
27
  import multiprocessing
28
+ import traceback
28
29
  import click
29
30
  import networkx as nx
30
31
  import hcl2
@@ -35,6 +36,7 @@ from datetime import datetime, timezone
35
36
  from .printer import Printer
36
37
  from .analyze import run_analyze
37
38
  from .deploy import prepare_deploy_config, run_sync
39
+ from .inspector import AzureInspector
38
40
 
39
41
  class DateTimeEncoder(json.JSONEncoder):
40
42
  def default(self, obj):
@@ -84,6 +86,10 @@ class TgWrap():
84
86
  )
85
87
  self.tg_source_indicator = None
86
88
 
89
+ # terragrunt do now prefer opentofu but we want this to be a conscious decision
90
+ if not os.environ.get('TERRAGRUNT_TFPATH'):
91
+ os.environ['TERRAGRUNT_TFPATH'] = 'terraform'
92
+
87
93
  def load_yaml_file(self, filepath):
88
94
  try:
89
95
  with open(filepath, 'r') as file:
@@ -776,23 +782,15 @@ class TgWrap():
776
782
  total_items = sum(len(group) for group in groups)
777
783
  self.printer.verbose(f'Executed {group_nbr} groups and {total_items} steps')
778
784
 
779
- def _post_analyze_results_to_dce(self, data_collection_endpoint:str, payload:object):
780
- """
781
- Posts the payload to the given (Azure) data collection endpoint
782
- """
783
-
784
- def mask_basic_auth(url):
785
- # Regular expression to match basic authentication credentials in URL
786
- auth_pattern = re.compile(r"(https?://)([^:@]+):([^:@]+)@(.+)")
787
- # Return the url without the basic auth part
788
- return auth_pattern.sub(r"\1\4", url)
789
-
785
+ def _get_access_token(self):
786
+ """Retrieve an access token"""
787
+
790
788
  #
791
789
  # Everything we do here, can be done using native python. And probably this is preferable as well.
792
790
  # But I have decided to follow (at least for now) the overall approach of the app and that is
793
791
  # executing systems commands.
794
792
  # This does require the az cli to be installed, but that is a fair assumption if you are working
795
- # with terragrunt/terraform and want to post the analyze results to a Data Collection Endpoint.
793
+ # with terragrunt/terraform and want to post the analyze results to an Azure Data Collection Endpoint.
796
794
  # However, not ruling out this will change, but then the change should be transparant.
797
795
  #
798
796
 
@@ -842,6 +840,60 @@ class TgWrap():
842
840
  if not token:
843
841
  raise Exception(f'Could not retrieve an access token:\n{json.dumps(output, indent=2)}')
844
842
 
843
+ return principal, token
844
+
845
+ def _post_to_dce(self, data_collection_endpoint, payload, token=None):
846
+
847
+ if not token:
848
+ _, token = self._get_access_token()
849
+
850
+ # DCE payload must be submitted as an arry
851
+ if not isinstance(payload, list):
852
+ dce_payload = [payload]
853
+ else:
854
+ dce_payload = payload
855
+
856
+ self.printer.verbose('About to log:')
857
+ self.printer.verbose(f'- to: {data_collection_endpoint}')
858
+ self.printer.verbose(f'- payload:\n{json.dumps(dce_payload, indent=2)}')
859
+
860
+ # now do the actual post
861
+ try:
862
+ headers = {
863
+ 'Authorization': f"Bearer {token}",
864
+ 'Content-Type': 'application/json',
865
+ }
866
+ resp = requests.post(
867
+ url=data_collection_endpoint,
868
+ headers=headers,
869
+ json=dce_payload,
870
+ )
871
+
872
+ resp.raise_for_status()
873
+ self.printer.success('Analyze results logged to DCE', print_line_before=True)
874
+
875
+ except requests.exceptions.RequestException as e:
876
+ # we warn but continue
877
+ self.printer.warning(f'Error while posting the analyze results ({type(e)}): {e}', print_line_before=True)
878
+ except Exception as e:
879
+ self.printer.error(f'Unexpected error: {e}')
880
+ if self.printer.print_verbose:
881
+ raise(e)
882
+ sys.exit(1)
883
+
884
+ def _post_analyze_results_to_dce(self, data_collection_endpoint:str, payload:object):
885
+ """
886
+ Posts the payload to the given (Azure) data collection endpoint
887
+ """
888
+
889
+ def mask_basic_auth(url):
890
+ # Regular expression to match basic authentication credentials in URL
891
+ auth_pattern = re.compile(r"(https?://)([^:@]+):([^:@]+)@(.+)")
892
+ # Return the url without the basic auth part
893
+ return auth_pattern.sub(r"\1\4", url)
894
+
895
+ principal, token = self._get_access_token()
896
+
845
897
  # Get the repo info
846
898
  rc = subprocess.run(
847
899
  shlex.split('git config --get remote.origin.url'),
@@ -882,51 +934,26 @@ class TgWrap():
882
934
  raise Exception(f'Could not get scope: {scope}')
883
935
 
884
936
  # So now we have everything, we can construct the final payload
885
- dce_payload = [
886
- {
887
- "scope": scope,
888
- "principal": principal,
889
- "repo": repo,
890
- "creations": payload.get("summary").get("creations"),
891
- "updates": payload.get("summary").get("updates"),
892
- "deletions": payload.get("summary").get("deletions"),
893
- "minor": payload.get("summary").get("minor"),
894
- "medium": payload.get("summary").get("medium"),
895
- "major": payload.get("summary").get("major"),
896
- "unknown": payload.get("summary").get("unknown"),
897
- "total": payload.get("summary").get("total"),
898
- "score": payload.get("summary").get("score"),
899
- "details": payload.get('details'),
900
- },
901
- ]
902
-
903
- self.printer.verbose('About to log:')
904
- self.printer.verbose(f'- to: {data_collection_endpoint}')
905
- self.printer.verbose(f'- payload:\n{json.dumps(dce_payload, indent=2)}')
906
-
907
- # now do the actual post
908
- try:
909
- headers = {
910
- 'Authorization': f"Bearer {token}",
911
- 'Content-Type': 'application/json',
912
- }
913
- resp = requests.post(
914
- url=data_collection_endpoint,
915
- headers=headers,
916
- json=dce_payload,
917
- )
918
-
919
- resp.raise_for_status()
920
- self.printer.success('Analyze results logged to DCE')
921
-
922
- except requests.exceptions.RequestException as e:
923
- # we warn but continue
924
- self.printer.warning(f'Error while posting the analyze results ({type(e)}): {e}')
925
- except Exception as e:
926
- self.printer.error(f'Unexpected error: {e}')
927
- if self.printer.print_verbose:
928
- raise(e)
929
- sys.exit(1)
937
+ payload = {
938
+ "scope": scope,
939
+ "principal": principal,
940
+ "repo": repo,
941
+ "creations": payload.get("summary").get("creations"),
942
+ "updates": payload.get("summary").get("updates"),
943
+ "deletions": payload.get("summary").get("deletions"),
944
+ "minor": payload.get("summary").get("minor"),
945
+ "medium": payload.get("summary").get("medium"),
946
+ "major": payload.get("summary").get("major"),
947
+ "unknown": payload.get("summary").get("unknown"),
948
+ "total": payload.get("summary").get("total"),
949
+ "score": payload.get("summary").get("score"),
950
+ "details": payload.get('details'),
951
+ }
952
+ self._post_to_dce(
953
+ payload=payload,
954
+ data_collection_endpoint=data_collection_endpoint,
955
+ token=token,
956
+ )
930
957
 
931
958
  self.printer.verbose('Done')
932
959
 
@@ -1768,9 +1795,9 @@ Note:
1768
1795
  def clean(self, working_dir):
1769
1796
  """ Clean the temporary files of a terragrunt/terraform project """
1770
1797
 
1771
- cmd = 'find . -name ".terragrunt-cache" -type d -exec rm -rf {} \; ; ' + \
1772
- 'find . -name ".terraform" -type d -exec rm -rf {} \; ; ' + \
1773
- 'find . -name "terragrunt-debug*" -type f -exec rm -rf {} \;'
1798
+ cmd = r'find . -name ".terragrunt-cache" -type d -exec rm -rf {} \; ; ' + \
1799
+ r'find . -name ".terraform" -type d -exec rm -rf {} \; ; ' + \
1800
+ r'find . -name "terragrunt-debug*" -type f -exec rm -rf {} \;'
1774
1801
 
1775
1802
  # we see the behaviour that with cleaning up large directories, it returns errorcode=1 upon first try
1776
1803
  # never to shy away from a questionable solution to make your life easier, we just run it again :-)
@@ -1819,7 +1846,7 @@ Note:
1819
1846
  current_release = match.group(1)
1820
1847
  if current_release not in release_commits:
1821
1848
  # remove the part between ()
1822
- pattern = re.compile('\(.*?\) ')
1849
+ pattern = re.compile(r'\(.*?\) ')
1823
1850
  updated_entry = pattern.sub('', entry)
1824
1851
  release_commits[current_release] = [updated_entry]
1825
1852
  elif current_release:
@@ -1875,3 +1902,62 @@ Note:
1875
1902
  # use the regular printer, to avoid it being sent to stderr
1876
1903
  print(changelog)
1877
1904
 
1905
+ def inspect(self, domain:str,substack:str, stage:str, azure_subscription_id:str, config_file:str,
1906
+ out:bool, data_collection_endpoint:str):
1907
+ """ Inspects the status of an Azure deployment """
1908
+
1909
+ inspector = AzureInspector(
1910
+ subscription_id=azure_subscription_id,
1911
+ domain=domain,
1912
+ substack=substack,
1913
+ stage=stage,
1914
+ config_file=config_file,
1915
+ verbose=self.printer.print_verbose,
1916
+ )
1917
+
1918
+ try:
1919
+ results = inspector.inspect()
1920
+
1921
+ # Report the status
1922
+ exit_code = 0
1923
+ self.printer.header('Inspection status:', print_line_before=True)
1924
+ for k,v in results.items():
1925
+ msg = f"{v['type']}: {k}\n\t-> Resource:\t{v.get('inspect_status_code', 'NC')} ({v.get('inspect_message', 'not found')})"
1926
+ if 'rbac_assignment_status_code' in v:
1927
+ msg = msg + f'\n\t-> RBAC:\t{v['rbac_assignment_status_code']} ({v.get('rbac_assignment_message')})'
1928
+ if v['inspect_status_code'] != 'OK' or v.get('rbac_assignment_status_code', 'OK') == 'NOK':
1929
+ self.printer.error(msg=msg)
1930
+ exit_code += 1
1931
+ else:
1932
+ self.printer.success(msg=msg)
1933
+
1934
+ if out or data_collection_endpoint:
1935
+ # convert results to something DCE understands, and add the inputs
1936
+ payload = []
1937
+ for key, value in results.items():
1938
+ value_with_key = value.copy()
1939
+ value_with_key["resource_type"] = value_with_key.pop("type")
1940
+ value_with_key["resource"] = key
1941
+ value_with_key["domain"] = domain
1942
+ value_with_key["substack"] = substack
1943
+ value_with_key["stage"] = stage
1944
+ value_with_key["subscription_id"] = azure_subscription_id
1945
+ payload.append(value_with_key)
1946
+
1947
+ if out:
1948
+ print(json.dumps(payload, indent=2))
1949
+
1950
+ if data_collection_endpoint:
1951
+ self._post_to_dce(
1952
+ data_collection_endpoint=data_collection_endpoint,
1953
+ payload=payload,
1954
+ )
1955
+
1956
+ return exit_code
1957
+ except Exception as e:
1958
+ self.printer.normal(f'Exception occurred: {e}')
1959
+
1960
+ if self.printer.print_verbose:
1961
+ traceback.print_exc()
1962
+
1963
+ return -1
tgwrap/printer.py CHANGED
@@ -69,3 +69,6 @@ class Printer():
69
69
  click.secho(msg, fg="green", bold=True, file=sys.stderr)
70
70
  self.line() if print_line_after else None
71
71
 
72
+ def progress_indicator(self):
73
+ print('.', flush=True, file=sys.stderr, end='')
74
+
@@ -1,22 +1,24 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tgwrap
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: A (terragrunt) wrapper around a (terraform) wrapper around ....
5
5
  Home-page: https://gitlab.com/lunadata/tgwrap
6
6
  License: MIT
7
7
  Keywords: terraform,terragrunt,terrasafe,python
8
8
  Author: Gerco Grandia
9
9
  Author-email: gerco.grandia@4synergy.nl
10
- Requires-Python: >=3.8,<4.0
10
+ Requires-Python: >=3.10,<4.0
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
13
  Classifier: Programming Language :: Python :: 3.10
16
14
  Classifier: Programming Language :: Python :: 3.11
17
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Dist: azure-core (>=1.30.2,<2.0.0)
17
+ Requires-Dist: azure-identity (>=1.17.1,<2.0.0)
18
+ Requires-Dist: azure-mgmt-authorization (>=4.0.0,<5.0.0)
18
19
  Requires-Dist: click (>=8.0)
19
20
  Requires-Dist: inquirer (>=3.1.4,<4.0.0)
21
+ Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
20
22
  Requires-Dist: networkx (>=2.8.8,<3.0.0)
21
23
  Requires-Dist: outdated (>=0.2.2)
22
24
  Requires-Dist: pydot (>=1.4.2,<2.0.0)
@@ -192,6 +194,25 @@ A payload as below will be posted, and the log analytics table should be able to
192
194
  ]
193
195
  ```
194
196
 
197
+ The log analytics (custom) table should have a schema that is able to cope with the message above:
198
+
199
+ | Field | Type |
200
+ |----------------|----------|
201
+ | creations | Int |
202
+ | deletions | Int |
203
+ | details | Dynamic |
204
+ | major | Int |
205
+ | medium | Int |
206
+ | minor | Int |
207
+ | principal | String |
208
+ | repo | String |
209
+ | scope | String |
210
+ | score | Int |
211
+ | TimeGenerated | Datetime |
212
+ | total | Int |
213
+ | unknown | Int |
214
+ | updates | Int |
215
+
195
216
  ## More than a wrapper
196
217
 
197
218
  Over time, tgwrap became more than a wrapper, blantly violating [#1 of the unix philosophy](https://en.wikipedia.org/wiki/Unix_philosophy#:~:text=The%20Unix%20philosophy%20is%20documented,%2C%20as%20yet%20unknown%2C%20program.): 'Make each program do one thing well'.
@@ -307,6 +328,90 @@ global_config_files:
307
328
  source: ../../terrasafe-config.json
308
329
  ```
309
330
 
331
+ ## Inspecting deployed infrastructure
332
+
333
+ Testing infra-as-code is hard, even though test frameworks are becoming more common these days. But the standard test approaches typically work with temporary infrastructures, while it is often also useful to test a deployed infrastructure.
334
+
335
+ Frameworks like [Chef's InSpec](https://docs.chef.io/inspec/) aims at solving that, but it is pretty config management heavy (but there are add-ons for aws and azure infra). It has a steep learning curve, we only need a tiny part of it, and also comes with a commercial license.
336
+
337
+ For what we need ('is infra deployed and are the main role assignments still in place') it was pretty easy to implement in python.
338
+
339
+ For this, you can now run the `inspect` command, which will then inspect real infrastructure and role assignments, and report back whether it meets the expectations (as declared in a config file):
340
+
341
+ ```yaml
342
+ ---
343
+ location:
344
+ code: westeurope
345
+ full: West Europe
346
+
347
+ # the entra id groups ar specified as a map as these will be checked for existence
348
+ # but also used for role assignment validation
349
+ entra_id_groups:
350
+ platform_admins: '{domain}-platform-admins'
351
+ cost_admins: '{domain}-cost-admins'
352
+ data_admins: '{domain}-data-admins'
353
+ just_testing: group-does-not-exist
354
+
355
+ # the resources to check
356
+ resources:
357
+ - identifier: 'kv-{domain}-euw-{stage}-base'
358
+ # due to length limitations in resource names, some shortening in the name might have taken place
359
+ # so you can provide alternative ids
360
+ alternative_ids:
361
+ - 'kv-{domain}-euw-{stage}-bs'
362
+ - 'kv{domain}euw{stage}bs'
363
+ - 'kv{domain}euw{stage}base'
364
+ type: key_vault
365
+ resource_group: 'rg-{domain}-euw-{stage}-base'
366
+ role_assignments:
367
+ - platform_admins: Owner
368
+ - platform_admins: Key Vault Secrets Officer
369
+ - data_admins: Key Vault Secrets Officer
370
+ ```
371
+
372
+ After which you can run the following:
373
+
374
+ ```console
375
+ tgwrap inspect -d domain -s sbx -a 886d4e58-a178-4c50-ae65-xxxxxxxxxx -c ./inspect-config.yml
376
+ ......
377
+
378
+ Inspection status:
379
+ entra_id_group: dps-platform-admins
380
+ -> Resource: OK (Resource dps-platform-admins of type entra_id_group OK)
381
+ entra_id_group: dps-cost-admins
382
+ -> Resource: OK (Resource dps-cost-admins of type entra_id_group OK)
383
+ entra_id_group: dps-data-admins
384
+ -> Resource: OK (Resource dps-data-admins of type entra_id_group OK)
385
+ entra_id_group: group-does-not-exist
386
+ -> Resource: NEX (Resource group-does-not-exist of type entra_id_group not found)
387
+ key_vault: kv-dps-euw-sbx-base
388
+ -> Resource: OK (Resource kv-dps-euw-sbx-base of type key_vault OK)
389
+ -> RBAC: NOK (Principal platform_admins has NOT role Owner assigned; )
390
+ subscription: 886d4e58-a178-4c50-ae65-xxxxxxxxxx
391
+ -> Resource: OK (Resource 886d4e58-a178-4c50-ae65-xxxxxxxxxx of type subscription OK)
392
+ -> RBAC: NC (Role assignments not checked)
393
+ ```
394
+
395
+ You can sent the results also to a data collection endpoint (seel also [Logging the results](#logging-the-results)).
396
+
397
+ For that, a custom table should exist with the following structure:
398
+
399
+
400
+ | Field | Type |
401
+ |----------------------------|----------|
402
+ | domain | String |
403
+ | substack | String |
404
+ | stage | String |
405
+ | subscription_id | String |
406
+ | resource_type | String |
407
+ | inspect_status_code | String |
408
+ | inspect_status | String |
409
+ | inspect_message | String |
410
+ | rbac_assignment_status_code| String |
411
+ | rbac_assignment_status | String |
412
+ | rbac_assignment_message | String |
413
+ | resource | String |
414
+
310
415
  ## Generating change logs
311
416
 
312
417
  tgwrap can generate a change log by running:
@@ -0,0 +1,13 @@
1
+ tgwrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tgwrap/analyze.py,sha256=TJvAKVIbWl8-8oxpTwXVBWU72q_XQKzUTlyMZ25cV2M,8728
3
+ tgwrap/cli.py,sha256=UokTSBp84HZ9IAwn0MlGMUjpjL76SILWm1GNlVHAqr0,31482
4
+ tgwrap/deploy.py,sha256=-fSk-Ix_HqrXY7KQX_L27TnFzIuhBHYv4xYJW6zRDN4,10243
5
+ tgwrap/inspector-resources-template.yml,sha256=goQaqyaTb_o1fAuCBHl-6xc1Rul63Byqau5RQ4qJvTo,2992
6
+ tgwrap/inspector.py,sha256=5pW7Ex1lkKRoXY6hZGbCNmSD2iRzgMSfqi9w7gb-AcY,16990
7
+ tgwrap/main.py,sha256=zKddIfI7-KHd1lX9xXh_tqgnIUa8uOxqDSxsVNW-klg,84544
8
+ tgwrap/printer.py,sha256=frn1PARd8A28mkRCYR6ybN2x0NBULhNOutn4l2U7REY,2754
9
+ tgwrap-0.10.0.dist-info/LICENSE,sha256=VT-AVxIXt3EQTC-7Hy1uPGnrDNJLqfcgLgJD78fiyx4,1065
10
+ tgwrap-0.10.0.dist-info/METADATA,sha256=kH2A2ZYjEQPKQ0iGbJKiachqmV1yELC3j1k5g7ZWVyM,17578
11
+ tgwrap-0.10.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
+ tgwrap-0.10.0.dist-info/entry_points.txt,sha256=H8X0PMPmd4aW7Y9iyChZ0Ug6RWGXqhRUvHH-6f6Mxz0,42
13
+ tgwrap-0.10.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- tgwrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- tgwrap/analyze.py,sha256=CsSaGv-be6ATy36z9X7x00gpKY59soJys2VbIzD-tmg,8726
3
- tgwrap/cli.py,sha256=w6IxXbHuok0D2KNKx6kEezHeNbMnGc09now0KtN80d4,29728
4
- tgwrap/deploy.py,sha256=-fSk-Ix_HqrXY7KQX_L27TnFzIuhBHYv4xYJW6zRDN4,10243
5
- tgwrap/main.py,sha256=9v8c7o32xmmkgEOgrUCiDCcivol4lRzauS6L0pvXdLw,81157
6
- tgwrap/printer.py,sha256=dkcOCPIPB-IP6pn8QMpa06xlcqPFVaDvxnz-QEpDJV0,2663
7
- tgwrap-0.9.3.dist-info/LICENSE,sha256=VT-AVxIXt3EQTC-7Hy1uPGnrDNJLqfcgLgJD78fiyx4,1065
8
- tgwrap-0.9.3.dist-info/METADATA,sha256=4QoTzQqkbDlKqK6aQXs8NvQZRAFrmUBkOoD-wZVetrw,13334
9
- tgwrap-0.9.3.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
10
- tgwrap-0.9.3.dist-info/entry_points.txt,sha256=H8X0PMPmd4aW7Y9iyChZ0Ug6RWGXqhRUvHH-6f6Mxz0,42
11
- tgwrap-0.9.3.dist-info/RECORD,,