tgwrap 0.9.4__py3-none-any.whl → 0.10.1__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/cli.py +67 -1
- tgwrap/inspector-resources-template.yml +63 -0
- tgwrap/inspector.py +438 -0
- tgwrap/main.py +234 -58
- tgwrap/printer.py +3 -0
- {tgwrap-0.9.4.dist-info → tgwrap-0.10.1.dist-info}/METADATA +108 -1
- tgwrap-0.10.1.dist-info/RECORD +13 -0
- {tgwrap-0.9.4.dist-info → tgwrap-0.10.1.dist-info}/WHEEL +1 -1
- tgwrap-0.9.4.dist-info/RECORD +0 -11
- {tgwrap-0.9.4.dist-info → tgwrap-0.10.1.dist-info}/LICENSE +0 -0
- {tgwrap-0.9.4.dist-info → tgwrap-0.10.1.dist-info}/entry_points.txt +0 -0
tgwrap/cli.py
CHANGED
@@ -750,6 +750,11 @@ def check_deployments(manifest_file, verbose, working_dir, out):
|
|
750
750
|
help='Whether or not external dependencies must be ignored',
|
751
751
|
show_default=True
|
752
752
|
)
|
753
|
+
@click.option('--analyze', '-a',
|
754
|
+
is_flag=True, default=False,
|
755
|
+
help='Show analysis of the graph',
|
756
|
+
show_default=True
|
757
|
+
)
|
753
758
|
@click.option('--include-dir', '-I',
|
754
759
|
multiple=True, default=[],
|
755
760
|
help=r'A glob of a directory that needs to be included, this option can be used multiple times. For example: -I "integrations/\*/\*"',
|
@@ -765,7 +770,7 @@ def check_deployments(manifest_file, verbose, working_dir, out):
|
|
765
770
|
)
|
766
771
|
@click.argument('terragrunt-args', nargs=-1, type=click.UNPROCESSED)
|
767
772
|
@click.version_option(version=__version__)
|
768
|
-
def show_graph(verbose, backwards, exclude_external_dependencies, working_dir, include_dir, exclude_dir, terragrunt_args):
|
773
|
+
def show_graph(verbose, backwards, exclude_external_dependencies, analyze, working_dir, include_dir, exclude_dir, terragrunt_args):
|
769
774
|
""" Shows the dependencies of a project """
|
770
775
|
|
771
776
|
check_latest_version(verbose)
|
@@ -774,6 +779,7 @@ def show_graph(verbose, backwards, exclude_external_dependencies, working_dir, i
|
|
774
779
|
tgwrap.show_graph(
|
775
780
|
backwards=backwards,
|
776
781
|
exclude_external_dependencies=exclude_external_dependencies,
|
782
|
+
analyze=analyze,
|
777
783
|
working_dir=working_dir,
|
778
784
|
include_dirs=include_dir,
|
779
785
|
exclude_dirs=exclude_dir,
|
@@ -839,6 +845,66 @@ def change_log(changelog_file, verbose, working_dir, include_nbr_of_releases):
|
|
839
845
|
include_nbr_of_releases=include_nbr_of_releases,
|
840
846
|
)
|
841
847
|
|
848
|
+
@main.command(
|
849
|
+
name="inspect",
|
850
|
+
context_settings=dict(
|
851
|
+
ignore_unknown_options=True,
|
852
|
+
),
|
853
|
+
)
|
854
|
+
@click.option('--domain', '-d',
|
855
|
+
help='Domain name used in naming the objects',
|
856
|
+
required=True,
|
857
|
+
)
|
858
|
+
@click.option('--substack', '-S',
|
859
|
+
help='Identifier that is needed to select the objects',
|
860
|
+
default=None,
|
861
|
+
)
|
862
|
+
@click.option('--stage', '-s',
|
863
|
+
help='Stage (environment) to verify',
|
864
|
+
required=True,
|
865
|
+
)
|
866
|
+
@click.option('--azure-subscription-id', '-a',
|
867
|
+
help='Azure subscription id',
|
868
|
+
required=True,
|
869
|
+
)
|
870
|
+
@click.option('--config-file', '-c',
|
871
|
+
help='Config file specifying the verifications',
|
872
|
+
required=True,
|
873
|
+
type=click.Path(),
|
874
|
+
)
|
875
|
+
@click.option('--out', '-o', is_flag=True, default=False,
|
876
|
+
help='Show output as json',
|
877
|
+
show_default=True
|
878
|
+
)
|
879
|
+
@click.option('--data-collection-endpoint', '-D', default=None,
|
880
|
+
help='Optional URI of an (Azure) data collection endpoint, to which the inspection results will be sent',
|
881
|
+
envvar='TGWRAP_INSPECT_DATA_COLLECTION_ENDPOINT',
|
882
|
+
show_default=True,
|
883
|
+
)
|
884
|
+
@click.option('--verbose', '-v', is_flag=True, default=False,
|
885
|
+
help='Verbose printing',
|
886
|
+
show_default=True
|
887
|
+
)
|
888
|
+
@click.version_option(version=__version__)
|
889
|
+
def inspect(domain, substack, stage, azure_subscription_id, config_file, out,
|
890
|
+
data_collection_endpoint, verbose):
|
891
|
+
""" Inspect the status of an (Azure) environment """
|
892
|
+
|
893
|
+
check_latest_version(verbose)
|
894
|
+
|
895
|
+
tgwrap = TgWrap(verbose=verbose)
|
896
|
+
exit = tgwrap.inspect(
|
897
|
+
domain=domain,
|
898
|
+
substack=substack,
|
899
|
+
stage=stage,
|
900
|
+
azure_subscription_id=azure_subscription_id,
|
901
|
+
out=out,
|
902
|
+
data_collection_endpoint=data_collection_endpoint,
|
903
|
+
config_file=config_file,
|
904
|
+
)
|
905
|
+
|
906
|
+
sys.exit(exit)
|
907
|
+
|
842
908
|
# this is needed for the vscode debugger to work
|
843
909
|
if __name__ == '__main__':
|
844
910
|
main()
|
@@ -0,0 +1,63 @@
|
|
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
|
+
machine_learning_workspace:
|
47
|
+
url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.MachineLearningServices/workspaces/{name}?api-version=2023-04-01
|
48
|
+
properties:
|
49
|
+
properties.provisioningState: Succeeded
|
50
|
+
location: "{{location_code}}"
|
51
|
+
data_factory:
|
52
|
+
url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.DataFactory/factories/{name}?api-version=2018-06-01
|
53
|
+
properties:
|
54
|
+
properties.provisioningState: Succeeded
|
55
|
+
properties.repoConfiguration.repositoryName: datafactory
|
56
|
+
properties.repoConfiguration.type: FactoryVSTSConfiguration
|
57
|
+
location: "{{location_code}}"
|
58
|
+
function_app:
|
59
|
+
url: https://management.azure.com/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Web/sites/{name}?api-version=2021-01-15
|
60
|
+
properties:
|
61
|
+
properties.state: Running
|
62
|
+
properties.enabled: true
|
63
|
+
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):
|
@@ -780,23 +782,15 @@ class TgWrap():
|
|
780
782
|
total_items = sum(len(group) for group in groups)
|
781
783
|
self.printer.verbose(f'Executed {group_nbr} groups and {total_items} steps')
|
782
784
|
|
783
|
-
def
|
784
|
-
"""
|
785
|
-
|
786
|
-
"""
|
787
|
-
|
788
|
-
def mask_basic_auth(url):
|
789
|
-
# Regular expression to match basic authentication credentials in URL
|
790
|
-
auth_pattern = re.compile(r"(https?://)([^:@]+):([^:@]+)@(.+)")
|
791
|
-
# Return the url without the basic auth part
|
792
|
-
return auth_pattern.sub(r"\1\4", url)
|
793
|
-
|
785
|
+
def _get_access_token(self):
|
786
|
+
"""Retrieve an access token"""
|
787
|
+
|
794
788
|
#
|
795
789
|
# Everything we do here, can be done using native python. And probably this is preferable as well.
|
796
790
|
# But I have decided to follow (at least for now) the overall approach of the app and that is
|
797
791
|
# executing systems commands.
|
798
792
|
# This does require the az cli to be installed, but that is a fair assumption if you are working
|
799
|
-
# with terragrunt/terraform and want to post the analyze results to
|
793
|
+
# with terragrunt/terraform and want to post the analyze results to an Azure Data Collection Endpoint.
|
800
794
|
# However, not ruling out this will change, but then the change should be transparant.
|
801
795
|
#
|
802
796
|
|
@@ -846,6 +840,60 @@ class TgWrap():
|
|
846
840
|
if not token:
|
847
841
|
raise Exception(f'Could not retrieve an access token:\n{json.dumps(output, indent=2)}')
|
848
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
|
+
|
849
897
|
# Get the repo info
|
850
898
|
rc = subprocess.run(
|
851
899
|
shlex.split('git config --get remote.origin.url'),
|
@@ -886,51 +934,26 @@ class TgWrap():
|
|
886
934
|
raise Exception(f'Could not get scope: {scope}')
|
887
935
|
|
888
936
|
# So now we have everything, we can construct the final payload
|
889
|
-
|
890
|
-
|
891
|
-
|
892
|
-
|
893
|
-
|
894
|
-
|
895
|
-
|
896
|
-
|
897
|
-
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
|
902
|
-
|
903
|
-
|
904
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
908
|
-
|
909
|
-
self.printer.verbose(f'- payload:\n{json.dumps(dce_payload, indent=2)}')
|
910
|
-
|
911
|
-
# now do the actual post
|
912
|
-
try:
|
913
|
-
headers = {
|
914
|
-
'Authorization': f"Bearer {token}",
|
915
|
-
'Content-Type': 'application/json',
|
916
|
-
}
|
917
|
-
resp = requests.post(
|
918
|
-
url=data_collection_endpoint,
|
919
|
-
headers=headers,
|
920
|
-
json=dce_payload,
|
921
|
-
)
|
922
|
-
|
923
|
-
resp.raise_for_status()
|
924
|
-
self.printer.success('Analyze results logged to DCE')
|
925
|
-
|
926
|
-
except requests.exceptions.RequestException as e:
|
927
|
-
# we warn but continue
|
928
|
-
self.printer.warning(f'Error while posting the analyze results ({type(e)}): {e}')
|
929
|
-
except Exception as e:
|
930
|
-
self.printer.error(f'Unexpected error: {e}')
|
931
|
-
if self.printer.print_verbose:
|
932
|
-
raise(e)
|
933
|
-
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
|
+
)
|
934
957
|
|
935
958
|
self.printer.verbose('Done')
|
936
959
|
|
@@ -1741,15 +1764,88 @@ Note:
|
|
1741
1764
|
except Exception:
|
1742
1765
|
pass
|
1743
1766
|
|
1744
|
-
def show_graph(self, backwards, exclude_external_dependencies, working_dir, include_dirs, exclude_dirs, terragrunt_args):
|
1767
|
+
def show_graph(self, backwards, exclude_external_dependencies, analyze, working_dir, include_dirs, exclude_dirs, terragrunt_args):
|
1745
1768
|
""" Shows the dependencies of a project """
|
1746
1769
|
|
1770
|
+
def set_json_dumps_default(obj):
|
1771
|
+
if isinstance(obj, set):
|
1772
|
+
return list(obj)
|
1773
|
+
raise TypeError
|
1774
|
+
|
1775
|
+
def calculate_dependencies(graph):
|
1776
|
+
dependencies = {}
|
1777
|
+
for node in graph.nodes:
|
1778
|
+
out_degree = graph.out_degree(node)
|
1779
|
+
in_degree = graph.in_degree(node)
|
1780
|
+
total_degree = out_degree + in_degree
|
1781
|
+
dependencies[node] = {
|
1782
|
+
'dependencies': out_degree,
|
1783
|
+
'dependent_on_it': in_degree,
|
1784
|
+
'total': total_degree,
|
1785
|
+
}
|
1786
|
+
|
1787
|
+
return dependencies
|
1788
|
+
|
1789
|
+
def calculate_graph_metrics(graph):
|
1790
|
+
|
1791
|
+
metrics = {}
|
1792
|
+
|
1793
|
+
# Degree centrality
|
1794
|
+
metric = {
|
1795
|
+
'values': dict(sorted(nx.degree_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
|
1796
|
+
'description': 'Shows the degree of each node relative to the number of nodes in the graph',
|
1797
|
+
}
|
1798
|
+
sorted_dependencies = sorted(dependencies.items(), key=lambda x: x[1]['total'], reverse=True)
|
1799
|
+
metrics['degree_centrality'] = metric
|
1800
|
+
|
1801
|
+
# Betweenness centrality
|
1802
|
+
metric = {
|
1803
|
+
'values': dict(sorted(nx.betweenness_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
|
1804
|
+
'description': 'Indicates nodes that frequently lie on shortest paths between other nodes',
|
1805
|
+
}
|
1806
|
+
metrics['betweenness_centrality'] = metric
|
1807
|
+
|
1808
|
+
# Closeness centrality
|
1809
|
+
metric = {
|
1810
|
+
'values': dict(sorted(nx.closeness_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
|
1811
|
+
'description': 'Reflects how quickly a node can reach other nodes in the graph',
|
1812
|
+
}
|
1813
|
+
metrics['closeness_centrality'] = metric
|
1814
|
+
|
1815
|
+
# Strongly Connected Components (SCC)
|
1816
|
+
metric = {
|
1817
|
+
'values': list(nx.strongly_connected_components(graph)),
|
1818
|
+
'description': 'Lists sets of nodes that are mutually reachable',
|
1819
|
+
}
|
1820
|
+
metrics['strongly_connected_components'] = metric
|
1821
|
+
|
1822
|
+
# Weakly Connected Components (WCC)
|
1823
|
+
metric = {
|
1824
|
+
'values': list(nx.weakly_connected_components(graph)),
|
1825
|
+
'description': 'Lists sets of nodes that are connected disregarding edge directions',
|
1826
|
+
}
|
1827
|
+
metrics['weakly_connected_components'] = metric
|
1828
|
+
|
1829
|
+
# Average Path Length (only if the graph is connected)
|
1830
|
+
if nx.is_strongly_connected(graph):
|
1831
|
+
metric = {
|
1832
|
+
'values': nx.average_shortest_path_length(graph),
|
1833
|
+
'description': 'Shows the average shortest path length, indicating the graph\'s efficiency',
|
1834
|
+
}
|
1835
|
+
metrics['average_path_length'] = metric
|
1836
|
+
|
1837
|
+
return metrics
|
1838
|
+
|
1747
1839
|
self.printer.verbose(f"Attempting to show dependencies")
|
1748
1840
|
if terragrunt_args:
|
1749
1841
|
self.printer.verbose(f"- with additional parameters: {' '.join(terragrunt_args)}")
|
1750
1842
|
|
1751
1843
|
"Runs the desired command in the directories as defined in the directed graph"
|
1752
1844
|
graph = self._get_di_graph(backwards=backwards, working_dir=working_dir)
|
1845
|
+
try:
|
1846
|
+
graph.remove_node(r'\n')
|
1847
|
+
except nx.exception.NetworkXError:
|
1848
|
+
pass
|
1753
1849
|
|
1754
1850
|
# first go through the groups and clean up where needed
|
1755
1851
|
groups = self._prepare_groups(
|
@@ -1769,6 +1865,27 @@ Note:
|
|
1769
1865
|
for directory in group:
|
1770
1866
|
self.printer.normal(f"- {directory}")
|
1771
1867
|
|
1868
|
+
if analyze:
|
1869
|
+
self.printer.header("Graph analysis", print_line_before=True)
|
1870
|
+
|
1871
|
+
self.printer.bold("Dependencies counts:", print_line_before=True)
|
1872
|
+
dependencies = calculate_dependencies(graph)
|
1873
|
+
sorted_dependencies = sorted(dependencies.items(), key=lambda x: x[1]['total'], reverse=True)
|
1874
|
+
for node, counts in sorted_dependencies:
|
1875
|
+
msg = f"""
|
1876
|
+
{node} ->
|
1877
|
+
\ttotal: {counts['total']}
|
1878
|
+
\tdependent on: {counts['dependent_on_it']}
|
1879
|
+
\tdependencies: {counts['dependencies']}
|
1880
|
+
"""
|
1881
|
+
self.printer.normal(msg)
|
1882
|
+
|
1883
|
+
metrics = calculate_graph_metrics(graph)
|
1884
|
+
for metric, item in metrics.items():
|
1885
|
+
self.printer.bold(f'Metric: {metric}')
|
1886
|
+
self.printer.normal(f'Description: {item["description"]}')
|
1887
|
+
self.printer.normal(json.dumps(item['values'], indent=2, default=set_json_dumps_default))
|
1888
|
+
|
1772
1889
|
def clean(self, working_dir):
|
1773
1890
|
""" Clean the temporary files of a terragrunt/terraform project """
|
1774
1891
|
|
@@ -1879,3 +1996,62 @@ Note:
|
|
1879
1996
|
# use the regular printer, to avoid it being sent to stderr
|
1880
1997
|
print(changelog)
|
1881
1998
|
|
1999
|
+
def inspect(self, domain:str,substack:str, stage:str, azure_subscription_id:str, config_file:str,
|
2000
|
+
out:bool, data_collection_endpoint:str):
|
2001
|
+
""" Inspects the status of an Azure deployment """
|
2002
|
+
|
2003
|
+
inspector = AzureInspector(
|
2004
|
+
subscription_id=azure_subscription_id,
|
2005
|
+
domain=domain,
|
2006
|
+
substack=substack,
|
2007
|
+
stage=stage,
|
2008
|
+
config_file=config_file,
|
2009
|
+
verbose=self.printer.print_verbose,
|
2010
|
+
)
|
2011
|
+
|
2012
|
+
try:
|
2013
|
+
results = inspector.inspect()
|
2014
|
+
|
2015
|
+
# Report the status
|
2016
|
+
exit_code = 0
|
2017
|
+
self.printer.header('Inspection status:', print_line_before=True)
|
2018
|
+
for k,v in results.items():
|
2019
|
+
msg = f"{v['type']}: {k}\n\t-> Resource:\t{v.get('inspect_status_code', 'NC')} ({v.get('inspect_message', 'not found')})"
|
2020
|
+
if 'rbac_assignment_status_code' in v:
|
2021
|
+
msg = msg + f'\n\t-> RBAC:\t{v['rbac_assignment_status_code']} ({v.get('rbac_assignment_message')})'
|
2022
|
+
if v['inspect_status_code'] != 'OK' or v.get('rbac_assignment_status_code', 'OK') == 'NOK':
|
2023
|
+
self.printer.error(msg=msg)
|
2024
|
+
exit_code += 1
|
2025
|
+
else:
|
2026
|
+
self.printer.success(msg=msg)
|
2027
|
+
|
2028
|
+
if out or data_collection_endpoint:
|
2029
|
+
# convert results to something DCE understands, and add the inputs
|
2030
|
+
payload = []
|
2031
|
+
for key, value in results.items():
|
2032
|
+
value_with_key = value.copy()
|
2033
|
+
value_with_key["resource_type"] = value_with_key.pop("type")
|
2034
|
+
value_with_key["resource"] = key
|
2035
|
+
value_with_key["domain"] = domain
|
2036
|
+
value_with_key["substack"] = substack
|
2037
|
+
value_with_key["stage"] = stage
|
2038
|
+
value_with_key["subscription_id"] = azure_subscription_id
|
2039
|
+
payload.append(value_with_key)
|
2040
|
+
|
2041
|
+
if out:
|
2042
|
+
print(json.dumps(payload, indent=2))
|
2043
|
+
|
2044
|
+
if data_collection_endpoint:
|
2045
|
+
self._post_to_dce(
|
2046
|
+
data_collection_endpoint=data_collection_endpoint,
|
2047
|
+
payload=payload,
|
2048
|
+
)
|
2049
|
+
|
2050
|
+
return exit_code
|
2051
|
+
except Exception as e:
|
2052
|
+
self.printer.normal(f'Exception occurred: {e}')
|
2053
|
+
|
2054
|
+
if self.printer.print_verbose:
|
2055
|
+
traceback.print_exc()
|
2056
|
+
|
2057
|
+
return -1
|
tgwrap/printer.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: tgwrap
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.10.1
|
4
4
|
Summary: A (terragrunt) wrapper around a (terraform) wrapper around ....
|
5
5
|
Home-page: https://gitlab.com/lunadata/tgwrap
|
6
6
|
License: MIT
|
@@ -13,8 +13,12 @@ Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.10
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
15
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)
|
16
19
|
Requires-Dist: click (>=8.0)
|
17
20
|
Requires-Dist: inquirer (>=3.1.4,<4.0.0)
|
21
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
|
18
22
|
Requires-Dist: networkx (>=2.8.8,<3.0.0)
|
19
23
|
Requires-Dist: outdated (>=0.2.2)
|
20
24
|
Requires-Dist: pydot (>=1.4.2,<2.0.0)
|
@@ -190,6 +194,25 @@ A payload as below will be posted, and the log analytics table should be able to
|
|
190
194
|
]
|
191
195
|
```
|
192
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
|
+
|
193
216
|
## More than a wrapper
|
194
217
|
|
195
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'.
|
@@ -305,6 +328,90 @@ global_config_files:
|
|
305
328
|
source: ../../terrasafe-config.json
|
306
329
|
```
|
307
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
|
+
|
308
415
|
## Generating change logs
|
309
416
|
|
310
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=0SFzLJA-deut81Mpt6Giq77WK26vTDcajyFnPIHXR5c,31649
|
4
|
+
tgwrap/deploy.py,sha256=-fSk-Ix_HqrXY7KQX_L27TnFzIuhBHYv4xYJW6zRDN4,10243
|
5
|
+
tgwrap/inspector-resources-template.yml,sha256=Mos8NDzzZ3VxdXgeiVL9cmQfRcIXIHMLf79_KLwdXu8,3297
|
6
|
+
tgwrap/inspector.py,sha256=5pW7Ex1lkKRoXY6hZGbCNmSD2iRzgMSfqi9w7gb-AcY,16990
|
7
|
+
tgwrap/main.py,sha256=pF0bNugAGGzAl_loMGybPW-9OT2IsDnYZiF8YNyFtVI,88581
|
8
|
+
tgwrap/printer.py,sha256=frn1PARd8A28mkRCYR6ybN2x0NBULhNOutn4l2U7REY,2754
|
9
|
+
tgwrap-0.10.1.dist-info/LICENSE,sha256=VT-AVxIXt3EQTC-7Hy1uPGnrDNJLqfcgLgJD78fiyx4,1065
|
10
|
+
tgwrap-0.10.1.dist-info/METADATA,sha256=4STwMwE_GoE1_iPpbZUZFmb8sO6M_bnoY9f4IEqeTyM,17578
|
11
|
+
tgwrap-0.10.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
12
|
+
tgwrap-0.10.1.dist-info/entry_points.txt,sha256=H8X0PMPmd4aW7Y9iyChZ0Ug6RWGXqhRUvHH-6f6Mxz0,42
|
13
|
+
tgwrap-0.10.1.dist-info/RECORD,,
|
tgwrap-0.9.4.dist-info/RECORD
DELETED
@@ -1,11 +0,0 @@
|
|
1
|
-
tgwrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
-
tgwrap/analyze.py,sha256=TJvAKVIbWl8-8oxpTwXVBWU72q_XQKzUTlyMZ25cV2M,8728
|
3
|
-
tgwrap/cli.py,sha256=ACQe3ghlCLSNw_V4mM3e3Vf93RPjDq-WT0rbEcE1YqY,29725
|
4
|
-
tgwrap/deploy.py,sha256=-fSk-Ix_HqrXY7KQX_L27TnFzIuhBHYv4xYJW6zRDN4,10243
|
5
|
-
tgwrap/main.py,sha256=ib3Jq1lOymem6qwbrXdPEspMFOlh-t5Kv7WeISjrkVE,81360
|
6
|
-
tgwrap/printer.py,sha256=dkcOCPIPB-IP6pn8QMpa06xlcqPFVaDvxnz-QEpDJV0,2663
|
7
|
-
tgwrap-0.9.4.dist-info/LICENSE,sha256=VT-AVxIXt3EQTC-7Hy1uPGnrDNJLqfcgLgJD78fiyx4,1065
|
8
|
-
tgwrap-0.9.4.dist-info/METADATA,sha256=u7BHLxVw_SfxBmgd0PQNPfCtY3c3bERNwlRbBTHxv5w,13235
|
9
|
-
tgwrap-0.9.4.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
10
|
-
tgwrap-0.9.4.dist-info/entry_points.txt,sha256=H8X0PMPmd4aW7Y9iyChZ0Ug6RWGXqhRUvHH-6f6Mxz0,42
|
11
|
-
tgwrap-0.9.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|