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 +2 -2
- tgwrap/cli.py +67 -7
- tgwrap/inspector-resources-template.yml +58 -0
- tgwrap/inspector.py +438 -0
- tgwrap/main.py +147 -61
- tgwrap/printer.py +3 -0
- {tgwrap-0.9.3.dist-info → tgwrap-0.10.0.dist-info}/METADATA +109 -4
- tgwrap-0.10.0.dist-info/RECORD +13 -0
- {tgwrap-0.9.3.dist-info → tgwrap-0.10.0.dist-info}/WHEEL +1 -1
- tgwrap-0.9.3.dist-info/RECORD +0 -11
- {tgwrap-0.9.3.dist-info → tgwrap-0.10.0.dist-info}/LICENSE +0 -0
- {tgwrap-0.9.3.dist-info → tgwrap-0.10.0.dist-info}/entry_points.txt +0 -0
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"\[(.+?)\]", "[[]
|
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"\[(.+?)\]", "[[]
|
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
|
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
|
780
|
-
"""
|
781
|
-
|
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
|
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
|
-
|
886
|
-
|
887
|
-
|
888
|
-
|
889
|
-
|
890
|
-
|
891
|
-
|
892
|
-
|
893
|
-
|
894
|
-
|
895
|
-
|
896
|
-
|
897
|
-
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
|
902
|
-
|
903
|
-
|
904
|
-
|
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
@@ -1,22 +1,24 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: tgwrap
|
3
|
-
Version: 0.
|
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.
|
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,,
|
tgwrap-0.9.3.dist-info/RECORD
DELETED
@@ -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,,
|
File without changes
|
File without changes
|