tgwrap 0.8.11__py3-none-any.whl → 0.11.2__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/main.py CHANGED
@@ -17,6 +17,7 @@ import sys
17
17
  import subprocess
18
18
  import shlex
19
19
  import shutil
20
+ import requests
20
21
  import re
21
22
  import tempfile
22
23
  import json
@@ -24,6 +25,7 @@ import yaml
24
25
  import threading
25
26
  import queue
26
27
  import multiprocessing
28
+ import traceback
27
29
  import click
28
30
  import networkx as nx
29
31
  import hcl2
@@ -34,13 +36,14 @@ from datetime import datetime, timezone
34
36
  from .printer import Printer
35
37
  from .analyze import run_analyze
36
38
  from .deploy import prepare_deploy_config, run_sync
39
+ from .inspector import AzureInspector
40
+
37
41
  class DateTimeEncoder(json.JSONEncoder):
38
42
  def default(self, obj):
39
43
  if isinstance(obj, datetime):
40
44
  return obj.isoformat()
41
45
  return super().default(obj)
42
46
 
43
-
44
47
  class TgWrap():
45
48
  """
46
49
  A wrapper around terragrunt with the sole purpose to make it a bit
@@ -50,7 +53,6 @@ class TgWrap():
50
53
  TERRAGRUNT_FILE='terragrunt.hcl'
51
54
  VERSION_FILE="version.hcl"
52
55
  LATEST_VERSION='latest'
53
- LOCATE_VERSION_FILE_MAX_LEVELS=3
54
56
  PLANFILE_NAME="planfile"
55
57
  TG_SOURCE_VAR="TERRAGRUNT_SOURCE"
56
58
  TG_SOURCE_MAP_VAR="TERRAGRUNT_SOURCE_MAP"
@@ -83,9 +85,13 @@ class TgWrap():
83
85
  )
84
86
  self.tg_source_indicator = None
85
87
 
88
+ # terragrunt do now prefer opentofu but we want this to be a conscious decision
89
+ if not os.environ.get('TERRAGRUNT_TFPATH'):
90
+ os.environ['TERRAGRUNT_TFPATH'] = 'terraform'
91
+
86
92
  def load_yaml_file(self, filepath):
87
93
  try:
88
- with open(filepath, 'r') as file:
94
+ with open(filepath.strip(), 'r') as file:
89
95
  return yaml.safe_load(file)
90
96
  except yaml.parser.ParserError as e:
91
97
  self.printer.error(f'Cannot parse YAML file {filepath}, check syntax please!')
@@ -102,8 +108,8 @@ class TgWrap():
102
108
  'info': '{base_command} terragrunt-info --terragrunt-non-interactive {update} {upgrade} {common}',
103
109
  'plan': '{base_command} {command} --terragrunt-non-interactive -out={planfile_name} {lock_level} {update} {parallelism} {common}',
104
110
  'apply': '{base_command} {command} {non_interactive} {no_auto_approve} {update} {parallelism} {common} {planfile}',
105
- 'show': '{base_command} {command} --terragrunt-non-interactive {planfile_name} {common}',
106
- 'destroy': '{base_command} {command} {non_interactive} {no_auto_approve} {parallelism} {common} {planfile}',
111
+ 'show': '{base_command} {command} --terragrunt-non-interactive {common} {planfile_name}',
112
+ 'destroy': '{base_command} {command} --terragrunt-no-destroy-dependencies-check {non_interactive} {no_auto_approve} {parallelism} {common} {planfile}',
107
113
  }
108
114
 
109
115
  lock_stmt = ''
@@ -215,9 +221,12 @@ class TgWrap():
215
221
  include_dirs = [dir.lstrip(f'.{os.path.sep}') for dir in include_dirs]
216
222
  exclude_dirs = [dir.lstrip(f'.{os.path.sep}') for dir in exclude_dirs]
217
223
 
224
+ # Below doesn't seem to work, at least when using `analyze`
225
+ # Not sure it has been added here in the first place
226
+
218
227
  # if the dir is not ending on '/*', add it
219
- include_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in include_dirs]
220
- exclude_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in exclude_dirs]
228
+ # include_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in include_dirs]
229
+ # exclude_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in exclude_dirs]
221
230
 
222
231
  common_path = os.path.commonpath([os.path.abspath(working_dir), os.path.abspath(directory)])
223
232
  self.printer.verbose(f'Common path for dir {directory}: {common_path}')
@@ -365,7 +374,7 @@ class TgWrap():
365
374
 
366
375
  return graph
367
376
 
368
- def _clone_repo(self, manifest, target_dir, version_tag=None):
377
+ def _clone_repo(self, repo, target_dir, version_tag=None):
369
378
  """Clones the repo, possibly a specific version, into a temp directory"""
370
379
 
371
380
  def get_tags(target_dir):
@@ -445,9 +454,7 @@ class TgWrap():
445
454
  return is_latest, is_branch, is_tag
446
455
 
447
456
  # clone the repo
448
- repo = manifest['git_repository']
449
457
  self.printer.verbose(f'Clone repo {repo}')
450
-
451
458
  cmd = f"git clone {repo} {target_dir}"
452
459
  rc = subprocess.run(
453
460
  shlex.split(cmd),
@@ -476,7 +483,7 @@ class TgWrap():
476
483
  working_dir=target_dir,
477
484
  )
478
485
 
479
- self.printer.header(f'Deploy using reference {version_tag}')
486
+ self.printer.header(f'Fetch repo using reference {version_tag}')
480
487
 
481
488
  if is_latest:
482
489
  pass # nothing to do, we already have latest
@@ -656,7 +663,7 @@ class TgWrap():
656
663
  self, command, exclude_external_dependencies, start_at_step, dry_run,
657
664
  parallel_execution=False, ask_for_confirmation=False, collect_output_file=None,
658
665
  backwards=False, working_dir=None, include_dirs=[], exclude_dirs=[],
659
- use_native_terraform=False, add_to_workdir=None,
666
+ use_native_terraform=False, add_to_workdir=None, continue_on_error=False,
660
667
  ):
661
668
  "Runs the desired command in the directories as defined in the directed graph"
662
669
 
@@ -753,7 +760,7 @@ class TgWrap():
753
760
  progress=progress,
754
761
  )
755
762
 
756
- if stop_processing:
763
+ if stop_processing and not continue_on_error:
757
764
  self.printer.warning(f"Processing needs to be stopped at step {step_nbr}.")
758
765
  self.printer.normal(
759
766
  f"After you've fixed the problem, you can continue where you left off by adding '--start-at-step {step_nbr}'."
@@ -775,10 +782,204 @@ class TgWrap():
775
782
  total_items = sum(len(group) for group in groups)
776
783
  self.printer.verbose(f'Executed {group_nbr} groups and {total_items} steps')
777
784
 
785
+ def _get_access_token(self):
786
+ """Retrieve an access token"""
787
+
788
+ #
789
+ # Everything we do here, can be done using native python. And probably this is preferable as well.
790
+ # But I have decided to follow (at least for now) the overall approach of the app and that is
791
+ # executing systems commands.
792
+ # This does require the az cli to be installed, but that is a fair assumption if you are working
793
+ # with terragrunt/terraform and want to post the analyze results to an Azure Data Collection Endpoint.
794
+ # However, not ruling out this will change, but then the change should be transparant.
795
+ #
796
+
797
+ # Get the Azure information
798
+ rc = subprocess.run(
799
+ shlex.split('az account show'),
800
+ check=True,
801
+ stdout=subprocess.PIPE,
802
+ stderr=sys.stderr,
803
+ )
804
+ self.printer.verbose(rc)
805
+
806
+ # Do a few checks
807
+ if rc.returncode != 0:
808
+ raise Exception(f'Could not get Azure account info')
809
+
810
+ # Get the ouptut
811
+ output = json.loads(rc.stdout.decode())
812
+ if output.get('environmentName') != 'AzureCloud':
813
+ raise Exception(f'Environment is not an Azure cloud:\n{json.dumps(output, indent=2)}')
814
+
815
+ tenant_id = output.get('tenantId')
816
+ if not tenant_id:
817
+ raise Exception(f'Could not determine Azure tenant id:\n{json.dumps(output, indent=2)}')
818
+
819
+ principal = output.get('user').get('name')
820
+ if not principal:
821
+ raise Exception(f'Could not determine principal:\n{json.dumps(output, indent=2)}')
822
+
823
+ # TOKEN=$(az account get-access-token --scope "https://monitor.azure.com//.default" | jq -r '.accessToken')
824
+ # Get the Azure OAUTH token
825
+ rc = subprocess.run(
826
+ shlex.split('az account get-access-token --scope "https://monitor.azure.com//.default"'),
827
+ check=True,
828
+ stdout=subprocess.PIPE,
829
+ stderr=sys.stderr,
830
+ )
831
+ self.printer.verbose(rc.returncode) # do not print the token to output
832
+
833
+ # Do a few checks
834
+ if rc.returncode != 0:
835
+ raise Exception(f'Could not get Azure OAUTH token')
836
+
837
+ # Get the ouptut
838
+ output = json.loads(rc.stdout.decode())
839
+ token = output.get('accessToken')
840
+ if not token:
841
+ raise Exception(f'Could not retrieve an access token:\n{json.dumps(output, indent=2)}')
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
+
897
+ # Get the repo info
898
+ rc = subprocess.run(
899
+ shlex.split('git config --get remote.origin.url'),
900
+ check=True,
901
+ stdout=subprocess.PIPE,
902
+ stderr=sys.stderr,
903
+ )
904
+ self.printer.verbose(rc)
905
+
906
+ # Do a few checks
907
+ if rc.returncode != 0:
908
+ raise Exception(f'Could not get git repo info')
909
+
910
+ # Get the ouptut
911
+ repo = rc.stdout.decode().rstrip('\n')
912
+ if not repo:
913
+ raise Exception(f'Could not get git repo info: {repo}')
914
+
915
+ # Remove the basic auth info if it is part of the url
916
+ repo = mask_basic_auth(repo)
917
+
918
+ # Get the current path in the repo
919
+ rc = subprocess.run(
920
+ shlex.split('git rev-parse --show-prefix'),
921
+ check=True,
922
+ stdout=subprocess.PIPE,
923
+ stderr=sys.stderr,
924
+ )
925
+ self.printer.verbose(rc)
926
+
927
+ # Do a few checks
928
+ if rc.returncode != 0:
929
+ raise Exception(f'Could not get current scope')
930
+
931
+ # Get the ouptut
932
+ scope = rc.stdout.decode().rstrip('\n')
933
+ if not scope:
934
+ raise Exception(f'Could not get scope: {scope}')
935
+
936
+ # So now we have everything, we can construct the final payload
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
+ )
957
+
958
+ self.printer.verbose('Done')
959
+
778
960
  def run(self, command, debug, dry_run, no_lock, update, upgrade,
779
961
  planfile, auto_approve, clean, working_dir, terragrunt_args):
780
962
  """ Executes a terragrunt command on a single module """
781
963
 
964
+ def extract_source_value(terragrunt_file_content):
965
+ # Regular expression to capture the terraform block
966
+ terraform_block_pattern = re.compile(r'terraform\s*\{(.*?)\n\}', re.DOTALL)
967
+
968
+ # Regular expression to capture the 'source' key and its value
969
+ source_pattern = re.compile(r'source\s*=\s*"(.*?)(?<!\\)"', re.DOTALL)
970
+
971
+ # Find the terraform block
972
+ terraform_block_match = terraform_block_pattern.search(terragrunt_file_content)
973
+ if terraform_block_match:
974
+ terraform_block = terraform_block_match.group(1)
975
+
976
+ # Search for the 'source' key within the block
977
+ source_match = source_pattern.search(terraform_block)
978
+ if source_match:
979
+ return source_match.group(1) # Return the value of 'source'
980
+ else:
981
+ raise ValueError('Could not locate the terragrunt source value')
982
+
782
983
  self.printer.verbose(f"Attempting to execute 'run {command}'")
783
984
  if terragrunt_args:
784
985
  self.printer.verbose(f"- with additional parameters: {' '.join(terragrunt_args)}")
@@ -786,6 +987,7 @@ class TgWrap():
786
987
  check_for_file=self.TERRAGRUNT_FILE
787
988
  if working_dir:
788
989
  check_for_file = os.path.join(working_dir, check_for_file)
990
+
789
991
  if not os.path.isfile(check_for_file):
790
992
  self.printer.error(
791
993
  f"{check_for_file} not found, this seems not to be a terragrunt module directory!"
@@ -796,13 +998,15 @@ class TgWrap():
796
998
  source_module = None
797
999
  with open(check_for_file, 'r') as file:
798
1000
  try:
799
- content = hcl2.load(file)
800
- source = content['terraform'][0]['source']
1001
+ content = file.read()
1002
+ source = extract_source_value(content)
1003
+
801
1004
  # get the source part, typically the last part after the double /.
802
1005
  # also remove a potential version element from it.
803
1006
  source_module = re.sub(r'\${[^}]*}', '', source.split('//')[::-1][0])
804
1007
  except Exception as e:
805
- self.printer.verbose(f'Could not parse terragrunt.hcl, error (of type {type(e)}) raised, but we fall back to default behaviour.')
1008
+ self.printer.warning(f'Could not parse terragrunt.hcl, but we fall back to default behaviour.')
1009
+ self.printer.verbose(f'error (of type {type(e)}) raised')
806
1010
  pass
807
1011
 
808
1012
  cmd = self._construct_command(
@@ -884,14 +1088,14 @@ class TgWrap():
884
1088
  # tgwrap state mv 'azuread_group.this["viewers"]' 'azuread_group.this["readers"]'
885
1089
  rc = subprocess.run(
886
1090
  shlex.split(cmd, posix=False),
887
- cwd=cwd,
1091
+ cwd=cwd if cwd else None,
888
1092
  )
889
1093
  self.printer.verbose(rc)
890
1094
 
891
1095
  sys.exit(rc.returncode)
892
1096
 
893
1097
  def run_all(self, command, debug, dry_run, no_lock, update, upgrade,
894
- exclude_external_dependencies, step_by_step, planfile, auto_approve, clean,
1098
+ exclude_external_dependencies, step_by_step, continue_on_error, planfile, auto_approve, clean,
895
1099
  working_dir, start_at_step, limit_parallelism, include_dirs, exclude_dirs, terragrunt_args):
896
1100
  """ Executes a terragrunt command across multiple modules """
897
1101
 
@@ -903,10 +1107,6 @@ class TgWrap():
903
1107
  modifying_command = (command.lower() in ['apply', 'destroy'])
904
1108
  auto_approve = auto_approve if modifying_command else True
905
1109
 
906
- # if the dir is not ending on '/*', add it
907
- include_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in include_dirs]
908
- exclude_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in exclude_dirs]
909
-
910
1110
  cmd = self._construct_command(
911
1111
  command=command,
912
1112
  allow_no_run_all=False,
@@ -933,11 +1133,16 @@ class TgWrap():
933
1133
  f'This command will be executed for each individual module:\n$ {cmd}'
934
1134
  )
935
1135
 
1136
+ # if the dir is not ending on '/*', add it
1137
+ include_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in include_dirs]
1138
+ exclude_dirs = [dir.rstrip(f'.{os.path.sep}*') + f'{os.path.sep}*' for dir in exclude_dirs]
1139
+
936
1140
  self._run_di_graph(
937
1141
  command=cmd,
938
1142
  exclude_external_dependencies=exclude_external_dependencies,
939
1143
  working_dir=working_dir,
940
1144
  ask_for_confirmation=(not auto_approve),
1145
+ continue_on_error=continue_on_error,
941
1146
  dry_run=dry_run,
942
1147
  start_at_step=start_at_step,
943
1148
  backwards=True if command.lower() in ['destroy'] else False,
@@ -1000,8 +1205,8 @@ class TgWrap():
1000
1205
  self.printer.verbose(rc)
1001
1206
 
1002
1207
  def analyze(self, exclude_external_dependencies, working_dir, start_at_step,
1003
- out, analyze_config, parallel_execution,
1004
- include_dirs, exclude_dirs, planfile_dir, terragrunt_args):
1208
+ out, analyze_config, parallel_execution, ignore_attributes, include_dirs, exclude_dirs,
1209
+ planfile_dir, data_collection_endpoint, terragrunt_args):
1005
1210
  """ Analyzes the plan files """
1006
1211
 
1007
1212
  def calculate_score(major: int, medium: int, minor: int) -> float :
@@ -1039,19 +1244,14 @@ class TgWrap():
1039
1244
  cmd = f"terraform show -json {self.PLANFILE_NAME}"
1040
1245
 
1041
1246
  config = None
1042
- if not analyze_config:
1043
- self.printer.warning(
1044
- f"Analyze config file is not set, this is required for checking for unauthorized deletions and drift detection scores!"
1045
- )
1046
- else:
1247
+ if analyze_config:
1047
1248
  self.printer.verbose(
1048
1249
  f"\nAnalyze using config {analyze_config}"
1049
1250
  )
1050
1251
  config = self.load_yaml_file(analyze_config)
1051
1252
 
1052
1253
  ts_validation_successful = True
1053
- changes = {}
1054
- drifts = {}
1254
+ details = {}
1055
1255
  try:
1056
1256
  # then run it and capture the output
1057
1257
  with tempfile.NamedTemporaryFile(mode='w+', prefix='tgwrap-', delete=False) as f:
@@ -1082,7 +1282,6 @@ class TgWrap():
1082
1282
  except IndexError:
1083
1283
  self.printer.warning(f'Could not determine planfile: {line[:100]}')
1084
1284
 
1085
-
1086
1285
  try:
1087
1286
  # plan file could be empty (except for new line) if module is skipped
1088
1287
  if len(plan_file) > 1:
@@ -1092,11 +1291,13 @@ class TgWrap():
1092
1291
  if 'exception' in data:
1093
1292
  raise Exception(data['exception'])
1094
1293
 
1095
- changes[module], ts_success = run_analyze(
1294
+ details[module], ts_success = run_analyze(
1096
1295
  config=config,
1097
1296
  data=data,
1098
1297
  verbose=self.printer.print_verbose,
1298
+ ignore_attributes=ignore_attributes,
1099
1299
  )
1300
+
1100
1301
  if not ts_success:
1101
1302
  ts_validation_successful = False
1102
1303
  else:
@@ -1113,6 +1314,7 @@ class TgWrap():
1113
1314
  "creations": 0,
1114
1315
  "updates": 0,
1115
1316
  "deletions": 0,
1317
+ "outputs": 0,
1116
1318
  "minor": 0,
1117
1319
  "medium": 0,
1118
1320
  "major": 0,
@@ -1122,10 +1324,15 @@ class TgWrap():
1122
1324
  }
1123
1325
 
1124
1326
  self.printer.header("Analysis results:", print_line_before=True)
1125
- for key, value in changes.items():
1327
+ for key, value in details.items():
1328
+ # if we want to ignore a few attributes
1329
+ if ignore_attributes:
1330
+ value['updates'] = [item for item in value['updates'] if item not in value['ignorable_updates']]
1331
+
1126
1332
  self.printer.header(f'Module: {key}')
1127
- if not value["all"]:
1333
+ if not value["all"] and not value["outputs"]:
1128
1334
  self.printer.success('No changes detected')
1335
+
1129
1336
  if value["unauthorized"]:
1130
1337
  self.printer.error('Unauthorized deletions:')
1131
1338
  for m in value["unauthorized"]:
@@ -1145,45 +1352,70 @@ class TgWrap():
1145
1352
  for m in value["updates"]:
1146
1353
  total_drifts["updates"] = total_drifts["updates"] + 1
1147
1354
  self.printer.normal(f'-> {m}')
1355
+ if value["ignorable_updates"]:
1356
+ if self.printer.print_verbose:
1357
+ self.printer.normal('Updates (ignored):')
1358
+ for m in value["ignorable_updates"]:
1359
+ self.printer.normal(f'-> {m}')
1360
+ else:
1361
+ self.printer.normal(f'Updates (ignored): {len(value["ignorable_updates"])} resources (add --verbose to see them)')
1362
+ if value["outputs"]:
1363
+ self.printer.normal('Output changes:')
1364
+ for m in value["outputs"]:
1365
+ total_drifts["outputs"] = total_drifts["outputs"] + 1
1366
+ self.printer.normal(f'-> {m}')
1148
1367
 
1149
- for key, value in changes.items():
1150
- for type in ["minor", "medium", "major", "unknown", "total"]:
1151
- total_drifts[type] += value["drifts"][type]
1152
-
1153
- # the formula below is just a way to achieve a numeric results that is coming from the various drift categories
1154
- value['drifts']['score'] = calculate_score(
1155
- major = value['drifts']['major'],
1156
- medium = value['drifts']['medium'],
1157
- minor = value['drifts']['minor'],
1158
- )
1159
- value['drifts']['score'] = value['drifts']['major'] * 10 + value['drifts']['medium'] + value['drifts']['minor'] / 10
1160
-
1161
- # the formula below is just a way to achieve a numeric results that is coming from the various drift categories
1162
- total_drift_score = total_drifts['major'] * 10 + total_drifts['medium'] + total_drifts['minor'] / 10
1163
- total_drifts['score'] = total_drift_score
1164
-
1165
- self.printer.header(f"Drift score: {total_drift_score} ({total_drifts['major']}.{total_drifts['medium']}.{total_drifts['minor']})")
1166
- if total_drifts["unknown"] > 0:
1167
- self.printer.warning(f"For {total_drifts['unknown']} resources, drift score is not configured, please update configuration!")
1168
- self.printer.warning('- Unknowns:')
1169
- for key, value in changes.items():
1170
- for m in value["unknowns"]:
1171
- self.printer.warning(f' -> {m}')
1368
+ if not analyze_config:
1369
+ self.printer.error(
1370
+ f"Analyze config file is not set, this is required for checking for unauthorized deletions and drift detection scores!",
1371
+ print_line_before=True,
1372
+ )
1373
+ else:
1374
+ for key, value in details.items():
1375
+ for type in ["minor", "medium", "major", "unknown", "total"]:
1376
+ total_drifts[type] += value["drifts"][type]
1377
+
1378
+ # the formula below is just a way to achieve a numeric results that is coming from the various drift categories
1379
+ value['drifts']['score'] = calculate_score(
1380
+ major = value['drifts']['major'],
1381
+ medium = value['drifts']['medium'],
1382
+ minor = value['drifts']['minor'],
1383
+ )
1384
+ value['drifts']['score'] = value['drifts']['major'] * 10 + value['drifts']['medium'] + value['drifts']['minor'] / 10
1172
1385
 
1173
- if out:
1386
+ # the formula below is just a way to achieve a numeric results that is coming from the various drift categories
1387
+ total_drift_score = total_drifts['major'] * 10 + total_drifts['medium'] + total_drifts['minor'] / 10
1388
+ total_drifts['score'] = total_drift_score
1389
+
1390
+ self.printer.header(f"Drift score: {total_drift_score} ({total_drifts['major']}.{total_drifts['medium']}.{total_drifts['minor']})")
1391
+ if total_drifts["unknown"] > 0:
1392
+ self.printer.warning(f"For {total_drifts['unknown']} resources, drift score is not configured, please update configuration!")
1393
+ self.printer.warning('- Unknowns:')
1394
+ for key, value in details.items():
1395
+ for m in value["unknowns"]:
1396
+ self.printer.warning(f' -> {m}')
1397
+
1398
+ if out or data_collection_endpoint:
1174
1399
  # in the output we convert the dict of dicts to a list of dicts as it makes processing
1175
1400
  # (e.g. by telegraph) easier.
1176
1401
  output = {
1177
- "changes": [],
1402
+ "details": [],
1178
1403
  "summary": {},
1179
1404
  }
1180
- for key, value in changes.items():
1405
+ for key, value in details.items():
1181
1406
  value['module'] = key
1182
- output["changes"].append(value)
1407
+ output["details"].append(value)
1183
1408
 
1184
1409
  output["summary"] = total_drifts
1185
1410
 
1186
- print(json.dumps(output, indent=4))
1411
+ if out:
1412
+ print(json.dumps(output, indent=4))
1413
+
1414
+ if data_collection_endpoint:
1415
+ self._post_analyze_results_to_dce(
1416
+ data_collection_endpoint=data_collection_endpoint,
1417
+ payload=output,
1418
+ )
1187
1419
 
1188
1420
  if not ts_validation_successful:
1189
1421
  self.printer.error("Analysis detected unauthorised deletions, please check your configuration!!!")
@@ -1299,7 +1531,7 @@ class TgWrap():
1299
1531
  source_config_dir = None
1300
1532
 
1301
1533
  version_tag, _, _ = self._clone_repo(
1302
- manifest=manifest,
1534
+ repo=manifest['git_repository'],
1303
1535
  target_dir=temp_dir,
1304
1536
  version_tag=version_tag,
1305
1537
  )
@@ -1314,6 +1546,10 @@ class TgWrap():
1314
1546
  # and make it unique
1315
1547
  substacks = set(substacks)
1316
1548
 
1549
+ # the manifest file supports both `sub_stacks` and `substack` config name. Annoying to be a bit autistic when it comes to naming :-/
1550
+ substack_configs = manifest.get('sub_stacks', {})
1551
+ substack_configs.update(manifest.get('substacks', {}))
1552
+
1317
1553
  for target_stage in target_stages:
1318
1554
  target_dir = os.path.join(working_dir, '', target_stage)
1319
1555
  self.printer.header(f'Deploy stage {target_stage} to {target_dir}...')
@@ -1323,6 +1559,8 @@ class TgWrap():
1323
1559
  pass
1324
1560
 
1325
1561
  deploy_actions = {}
1562
+ deploy_global_configs = include_global_config_files
1563
+ target_stage_found = False
1326
1564
  # now go through the deploy configurations and apply the one that is relevant
1327
1565
  for key, value in manifest['deploy'].items():
1328
1566
  if target_stage not in value['applies_to_stages']:
@@ -1337,13 +1575,15 @@ class TgWrap():
1337
1575
  target_dir=target_dir,
1338
1576
  target_stage=target_stage,
1339
1577
  substacks=substacks,
1340
- substack_configs=manifest.get('sub_stacks', {}).items(),
1578
+ substack_configs=substack_configs.items(),
1341
1579
  tg_file_name=self.TERRAGRUNT_FILE,
1342
1580
  verbose=self.printer.print_verbose,
1343
1581
  )
1344
- )
1582
+ )
1583
+ deploy_global_configs = value.get('include_global_config_files', deploy_global_configs)
1584
+ target_stage_found = True
1345
1585
 
1346
- if include_global_config_files:
1586
+ if target_stage_found and deploy_global_configs:
1347
1587
  for gc, global_config in manifest.get('global_config_files', {}).items():
1348
1588
  self.printer.verbose(f'Found global config : {gc}')
1349
1589
 
@@ -1366,42 +1606,46 @@ class TgWrap():
1366
1606
  else:
1367
1607
  self.printer.verbose(f'Skipping global configs')
1368
1608
 
1369
- self.printer.header('Modules to deploy:')
1370
- self.printer.normal(f'-> git repository: {manifest["git_repository"]}')
1371
- self.printer.normal(f'-> version tag: {version_tag}')
1372
- self.printer.normal('Modules:')
1373
- for key, value in deploy_actions.items():
1374
- self.printer.normal(f'--> {key}')
1375
-
1376
- if not auto_approve:
1377
- response = input("\nDo you want to continue? (y/N) ")
1378
- if response.lower() != "y":
1379
- sys.exit(1)
1380
-
1381
- for key, value in deploy_actions.items():
1382
- run_sync(
1383
- source_path=value['source'],
1384
- target_path=value['target'],
1385
- excludes=value.get('excludes', []),
1386
- include_lock_file=True,
1387
- auto_approve=True,
1388
- dry_run=dry_run,
1389
- clean=False,
1390
- terragrunt_file=self.TERRAGRUNT_FILE,
1391
- verbose=self.printer.print_verbose,
1392
- )
1609
+ if deploy_actions:
1610
+ self.printer.header('Modules to deploy:')
1611
+ self.printer.normal(f'-> git repository: {manifest["git_repository"]}')
1612
+ self.printer.normal(f'-> version tag: {version_tag}')
1613
+ self.printer.normal('Modules:')
1614
+ for key, value in deploy_actions.items():
1615
+ self.printer.normal(f'--> {key}')
1616
+
1617
+ if not auto_approve:
1618
+ response = input("\nDo you want to continue? (y/N) ")
1619
+ # if response.lower() != "y":
1620
+ # sys.exit(1)
1621
+
1622
+ if auto_approve or response.lower() == "y":
1623
+ for key, value in deploy_actions.items():
1624
+ run_sync(
1625
+ source_path=value['source'],
1626
+ target_path=value['target'],
1627
+ excludes=value.get('excludes', []),
1628
+ include_lock_file=True,
1629
+ auto_approve=True,
1630
+ dry_run=dry_run,
1631
+ clean=False,
1632
+ terragrunt_file=self.TERRAGRUNT_FILE,
1633
+ verbose=self.printer.print_verbose,
1634
+ )
1393
1635
 
1394
- if not dry_run:
1395
- # write the version file
1396
- with open(os.path.join(target_dir, self.VERSION_FILE), 'w') as f:
1397
- f.write(f"""
1398
- locals {{
1399
- version_tag="{version_tag}"
1400
- }}
1401
- """)
1636
+ if not dry_run:
1637
+ # write the version file
1638
+ with open(os.path.join(target_dir, self.VERSION_FILE), 'w') as f:
1639
+ f.write(f"""
1640
+ locals {{
1641
+ version_tag="{version_tag}"
1642
+ }}
1643
+ """)
1644
+ else:
1645
+ self.printer.normal('Nothing to do')
1402
1646
 
1403
- # clean up the cache in the deployed directory to avoid strange issues when planning
1404
- self.clean(working_dir=target_dir)
1647
+ # clean up the cache in the deployed directory to avoid strange issues when planning
1648
+ self.clean(working_dir=target_dir)
1405
1649
 
1406
1650
  except KeyError as e:
1407
1651
  self.printer.error(f'Error interpreting the manifest file. Please ensure it uses the proper format. Could not find element: {e}')
@@ -1417,30 +1661,72 @@ class TgWrap():
1417
1661
  except Exception:
1418
1662
  pass
1419
1663
 
1420
- def check_deployments(self, manifest_file, working_dir, out):
1664
+ def check_deployments(self, repo_url, levels_deep, working_dir, out):
1421
1665
  """ Check the freshness of deployed configuration versions against the platform repository """
1422
1666
 
1423
- def locate_version_files(current_directory, found_files=[], root_directory=None, level=1):
1667
+ def locate_version_files(current_directory, found_files=[], root_directory=None, level=1, git_status=''):
1424
1668
  " This tries to find a version file in the current directory, or a given number of directories beneath it"
1425
1669
 
1670
+ # do not include hidden directories
1671
+ if os.path.basename(current_directory).startswith('.'):
1672
+ return found_files
1673
+
1426
1674
  if not root_directory:
1427
1675
  root_directory = current_directory
1428
1676
 
1677
+ if not git_status:
1678
+ self.printer.verbose(f'Check for git status in directory {current_directory}')
1679
+ # Execute 'git status' to get an overview of the current status
1680
+ cmd = "git status"
1681
+ rc = subprocess.run(
1682
+ shlex.split(cmd),
1683
+ cwd=current_directory,
1684
+ universal_newlines=True,
1685
+ stdout=subprocess.PIPE,
1686
+ stderr=subprocess.PIPE,
1687
+ )
1688
+ output = ('stdout: ' + rc.stdout + 'stderr: ' + rc.stderr).lower()
1689
+ if 'not a git repository' in output:
1690
+ pass
1691
+ elif 'branch is up to date' in output:
1692
+ git_status = 'up to date; '
1693
+ elif 'head detached' in output:
1694
+ git_status = 'head detached; '
1695
+ elif 'untracked files' in output:
1696
+ git_status = git_status + 'untracked files; '
1697
+ elif 'changes to be committed' in output:
1698
+ git_status = git_status + 'staged changes; '
1699
+ elif 'changes not staged for commit' in output:
1700
+ git_status = git_status + 'unstaged changes; '
1701
+ elif 'branch is ahead of' in output:
1702
+ git_status = git_status + 'ahead of remote; '
1703
+ elif 'branch is behind of' in output:
1704
+ git_status = git_status + 'behind remote; '
1705
+ elif 'unmerged paths' in output:
1706
+ git_status = git_status + 'merge conflicts; '
1707
+
1429
1708
  for entry in os.listdir(current_directory):
1430
1709
  full_entry = os.path.join(current_directory, entry)
1431
- if os.path.isdir(full_entry) and level < self.LOCATE_VERSION_FILE_MAX_LEVELS:
1710
+
1711
+ if os.path.isdir(full_entry) and level <= levels_deep:
1432
1712
  found_files = locate_version_files(
1433
1713
  current_directory=full_entry,
1434
1714
  found_files=found_files,
1435
1715
  root_directory=root_directory,
1436
1716
  level=level+1,
1717
+ git_status=git_status,
1437
1718
  )
1438
1719
  elif entry == self.VERSION_FILE:
1439
- found_files.append(os.path.relpath(current_directory, root_directory))
1720
+ found_files.append(
1721
+ {
1722
+ 'path': os.path.relpath(current_directory, root_directory),
1723
+ 'git_status': git_status,
1724
+ }
1725
+ )
1440
1726
 
1441
1727
  return found_files
1442
1728
 
1443
- def get_all_version(repo_dir, min_version=None):
1729
+ def get_all_versions(repo_dir, min_version=None):
1444
1730
  "Get all the version tags from the repo including their data"
1445
1731
 
1446
1732
  # Execute 'git tag' command to get a list of all tags
@@ -1471,25 +1757,27 @@ class TgWrap():
1471
1757
  try:
1472
1758
  # do we have a working dir?
1473
1759
  working_dir = working_dir if working_dir else os.getcwd()
1474
- self.printer.header(f'Check released versions ({self.LOCATE_VERSION_FILE_MAX_LEVELS} levels) in directory: {working_dir}')
1760
+ self.printer.header(f'Check released versions (max {levels_deep} levels deep) in directory: {working_dir}')
1475
1761
 
1476
- result = locate_version_files(working_dir)
1762
+ found_files = locate_version_files(working_dir)
1477
1763
 
1478
1764
  versions = []
1479
- for location in result:
1765
+ for result in found_files:
1480
1766
  # Determine the deployed version as defined in the version file
1481
- with open(os.path.join(working_dir, location, self.VERSION_FILE), 'r') as file:
1767
+ with open(os.path.join(working_dir, result['path'], self.VERSION_FILE), 'r') as file:
1768
+ # todo: replace this with regex as it is (now) the only reason we use this lib
1482
1769
  content = hcl2.load(file)
1483
1770
  try:
1484
1771
  version_tag = content['locals'][0]['version_tag']
1485
1772
  versions.append(
1486
1773
  {
1487
- 'path': location,
1774
+ 'path': result['path'],
1775
+ 'git_status': result['git_status'],
1488
1776
  'tag': version_tag
1489
1777
  }
1490
1778
  )
1491
1779
  except KeyError as e:
1492
- versions.append({location: 'unknown'})
1780
+ versions.append({result: 'unknown'})
1493
1781
 
1494
1782
  self.printer.verbose(f'Detected versions: {versions}')
1495
1783
 
@@ -1506,11 +1794,14 @@ class TgWrap():
1506
1794
  self.printer.verbose(f'Detected minimum version {min_version} and maximum version {max_version}')
1507
1795
 
1508
1796
  temp_dir = os.path.join(tempfile.mkdtemp(prefix='tgwrap-'), "tg-source")
1509
- manifest = self.load_yaml_file(os.path.join(working_dir, manifest_file))
1510
- self._clone_repo(manifest=manifest, target_dir=temp_dir)
1797
+ self._clone_repo(
1798
+ repo=repo_url,
1799
+ target_dir=temp_dir,
1800
+ version_tag='latest',
1801
+ )
1511
1802
 
1512
1803
  # determine the version tag from the repo, including their date
1513
- all_versions = get_all_version(repo_dir=temp_dir, min_version=min_version['tag'])
1804
+ all_versions = get_all_versions(repo_dir=temp_dir, min_version=min_version['tag'])
1514
1805
 
1515
1806
  # so now we can determine how old the deployed versions are
1516
1807
  now = datetime.now(timezone.utc)
@@ -1524,31 +1815,45 @@ class TgWrap():
1524
1815
  version['days_since_release'] = (now - release_date).days
1525
1816
 
1526
1817
  self.printer.header(
1527
- 'Deployed versions:' if len(versions) > 0 else 'No deployed versions detected'
1818
+ 'Deployed versions:' if len(versions) > 0 else 'No deployed versions detected',
1819
+ print_line_before=True,
1528
1820
  )
1529
-
1821
+
1822
+ # sort the list based on its path
1823
+ versions = sorted(versions, key=lambda x: x['path'])
1824
+
1530
1825
  for version in versions:
1531
1826
  days_since_release = version.get("days_since_release", 0)
1532
1827
  message = f'-> {version["path"]}: {version["tag"]} (released {days_since_release} days ago)'
1533
1828
  if version['release_date'] == 'unknown':
1534
1829
  self.printer.normal(message)
1535
- elif days_since_release > 60:
1830
+ elif days_since_release > 120:
1536
1831
  self.printer.error(message)
1537
- elif days_since_release > 30:
1538
- self.printer.error(message)
1539
- elif days_since_release < 7:
1832
+ elif days_since_release > 80:
1833
+ self.printer.warning(message)
1834
+ elif days_since_release < 40:
1540
1835
  self.printer.success(message)
1541
1836
  else:
1542
1837
  self.printer.normal(message)
1543
1838
 
1839
+ if version.get('git_status'):
1840
+ message = f'WARNING: git status: {version["git_status"].strip()}'
1841
+ if not 'up to date' in message:
1842
+ self.printer.warning(message)
1843
+
1544
1844
  self.printer.normal("\n") # just to get an empty line :-/
1545
1845
  self.printer.warning("""
1546
1846
  Note:
1547
1847
  This result only says something about the freshness of the deployed configurations,
1548
1848
  but not whether the actual resources are in sync with these.
1849
+
1549
1850
  Check the drift of these configurations with the actual deployments by
1550
1851
  planning and analyzing the results.
1551
- """)
1852
+
1853
+ Also, it uses the locally checked out repositories, make sure these are pulled so that
1854
+ this reflect the most up to date situation!
1855
+ """,
1856
+ print_line_before=True, print_line_after=True)
1552
1857
 
1553
1858
  if out:
1554
1859
  # use the regular printer, to avoid it being sent to stderr
@@ -1570,15 +1875,88 @@ Note:
1570
1875
  except Exception:
1571
1876
  pass
1572
1877
 
1573
- def show_graph(self, backwards, exclude_external_dependencies, working_dir, include_dirs, exclude_dirs, terragrunt_args):
1878
+ def show_graph(self, backwards, exclude_external_dependencies, analyze, working_dir, include_dirs, exclude_dirs, terragrunt_args):
1574
1879
  """ Shows the dependencies of a project """
1575
1880
 
1881
+ def set_json_dumps_default(obj):
1882
+ if isinstance(obj, set):
1883
+ return list(obj)
1884
+ raise TypeError
1885
+
1886
+ def calculate_dependencies(graph):
1887
+ dependencies = {}
1888
+ for node in graph.nodes:
1889
+ out_degree = graph.out_degree(node)
1890
+ in_degree = graph.in_degree(node)
1891
+ total_degree = out_degree + in_degree
1892
+ dependencies[node] = {
1893
+ 'dependencies': out_degree,
1894
+ 'dependent_on_it': in_degree,
1895
+ 'total': total_degree,
1896
+ }
1897
+
1898
+ return dependencies
1899
+
1900
+ def calculate_graph_metrics(graph):
1901
+
1902
+ metrics = {}
1903
+
1904
+ # Degree centrality
1905
+ metric = {
1906
+ 'values': dict(sorted(nx.degree_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
1907
+ 'description': 'Shows the degree of each node relative to the number of nodes in the graph',
1908
+ }
1909
+ sorted_dependencies = sorted(dependencies.items(), key=lambda x: x[1]['total'], reverse=True)
1910
+ metrics['degree_centrality'] = metric
1911
+
1912
+ # Betweenness centrality
1913
+ metric = {
1914
+ 'values': dict(sorted(nx.betweenness_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
1915
+ 'description': 'Indicates nodes that frequently lie on shortest paths between other nodes',
1916
+ }
1917
+ metrics['betweenness_centrality'] = metric
1918
+
1919
+ # Closeness centrality
1920
+ metric = {
1921
+ 'values': dict(sorted(nx.closeness_centrality(graph).items(), key=lambda item: item[1], reverse=True)),
1922
+ 'description': 'Reflects how quickly a node can reach other nodes in the graph',
1923
+ }
1924
+ metrics['closeness_centrality'] = metric
1925
+
1926
+ # Strongly Connected Components (SCC)
1927
+ metric = {
1928
+ 'values': list(nx.strongly_connected_components(graph)),
1929
+ 'description': 'Lists sets of nodes that are mutually reachable',
1930
+ }
1931
+ metrics['strongly_connected_components'] = metric
1932
+
1933
+ # Weakly Connected Components (WCC)
1934
+ metric = {
1935
+ 'values': list(nx.weakly_connected_components(graph)),
1936
+ 'description': 'Lists sets of nodes that are connected disregarding edge directions',
1937
+ }
1938
+ metrics['weakly_connected_components'] = metric
1939
+
1940
+ # Average Path Length (only if the graph is connected)
1941
+ if nx.is_strongly_connected(graph):
1942
+ metric = {
1943
+ 'values': nx.average_shortest_path_length(graph),
1944
+ 'description': 'Shows the average shortest path length, indicating the graph\'s efficiency',
1945
+ }
1946
+ metrics['average_path_length'] = metric
1947
+
1948
+ return metrics
1949
+
1576
1950
  self.printer.verbose(f"Attempting to show dependencies")
1577
1951
  if terragrunt_args:
1578
1952
  self.printer.verbose(f"- with additional parameters: {' '.join(terragrunt_args)}")
1579
1953
 
1580
1954
  "Runs the desired command in the directories as defined in the directed graph"
1581
1955
  graph = self._get_di_graph(backwards=backwards, working_dir=working_dir)
1956
+ try:
1957
+ graph.remove_node(r'\n')
1958
+ except nx.exception.NetworkXError:
1959
+ pass
1582
1960
 
1583
1961
  # first go through the groups and clean up where needed
1584
1962
  groups = self._prepare_groups(
@@ -1598,12 +1976,33 @@ Note:
1598
1976
  for directory in group:
1599
1977
  self.printer.normal(f"- {directory}")
1600
1978
 
1979
+ if analyze:
1980
+ self.printer.header("Graph analysis", print_line_before=True)
1981
+
1982
+ self.printer.bold("Dependencies counts:", print_line_before=True)
1983
+ dependencies = calculate_dependencies(graph)
1984
+ sorted_dependencies = sorted(dependencies.items(), key=lambda x: x[1]['total'], reverse=True)
1985
+ for node, counts in sorted_dependencies:
1986
+ msg = f"""
1987
+ {node} ->
1988
+ \ttotal: {counts['total']}
1989
+ \tdependent on: {counts['dependent_on_it']}
1990
+ \tdependencies: {counts['dependencies']}
1991
+ """
1992
+ self.printer.normal(msg)
1993
+
1994
+ metrics = calculate_graph_metrics(graph)
1995
+ for metric, item in metrics.items():
1996
+ self.printer.bold(f'Metric: {metric}')
1997
+ self.printer.normal(f'Description: {item["description"]}')
1998
+ self.printer.normal(json.dumps(item['values'], indent=2, default=set_json_dumps_default))
1999
+
1601
2000
  def clean(self, working_dir):
1602
2001
  """ Clean the temporary files of a terragrunt/terraform project """
1603
2002
 
1604
- cmd = 'find . -name ".terragrunt-cache" -type d -exec rm -rf {} \; ; ' + \
1605
- 'find . -name ".terraform" -type d -exec rm -rf {} \; ; ' + \
1606
- 'find . -name "terragrunt-debug*" -type f -exec rm -rf {} \;'
2003
+ cmd = r'find . -name ".terragrunt-cache" -type d -exec rm -rf {} \; ; ' + \
2004
+ r'find . -name ".terraform" -type d -exec rm -rf {} \; ; ' + \
2005
+ r'find . -name "terragrunt-debug*" -type f -exec rm -rf {} \;'
1607
2006
 
1608
2007
  # we see the behaviour that with cleaning up large directories, it returns errorcode=1 upon first try
1609
2008
  # never to shy away from a questionable solution to make your life easier, we just run it again :-)
@@ -1652,7 +2051,7 @@ Note:
1652
2051
  current_release = match.group(1)
1653
2052
  if current_release not in release_commits:
1654
2053
  # remove the part between ()
1655
- pattern = re.compile('\(.*?\) ')
2054
+ pattern = re.compile(r'\(.*?\) ')
1656
2055
  updated_entry = pattern.sub('', entry)
1657
2056
  release_commits[current_release] = [updated_entry]
1658
2057
  elif current_release:
@@ -1708,3 +2107,65 @@ Note:
1708
2107
  # use the regular printer, to avoid it being sent to stderr
1709
2108
  print(changelog)
1710
2109
 
2110
+ def inspect(self, domain:str,substack:str, stage:str, azure_subscription_id:str, config_file:str,
2111
+ out:bool, data_collection_endpoint:str):
2112
+ """ Inspects the status of an Azure deployment """
2113
+
2114
+ inspector = AzureInspector(
2115
+ subscription_id=azure_subscription_id,
2116
+ domain=domain,
2117
+ substack=substack,
2118
+ stage=stage,
2119
+ config_file=config_file,
2120
+ verbose=self.printer.print_verbose,
2121
+ )
2122
+
2123
+ try:
2124
+ results = inspector.inspect()
2125
+
2126
+ # Report the status
2127
+ exit_code = 0
2128
+ self.printer.header('Inspection status:', print_line_before=True)
2129
+ for k,v in results.items():
2130
+ msg = f"""{v['type']}: {k}
2131
+ -> Resource: {v.get('inspect_status_code', 'NC')} ({v.get('inspect_message', 'not found')})""" # only since python 3.12 you can use things like \t and \n in an f-string
2132
+ if 'rbac_assignment_status_code' in v:
2133
+ msg = msg + f"""
2134
+ -> RBAC: {v['rbac_assignment_status_code']} ({v.get('rbac_assignment_message')})"
2135
+ """ # only since python 3.12 you can use things like \t and \n in an f-string
2136
+ if v['inspect_status_code'] != 'OK' or v.get('rbac_assignment_status_code', 'OK') == 'NOK':
2137
+ self.printer.error(msg=msg)
2138
+ exit_code += 1
2139
+ else:
2140
+ self.printer.success(msg=msg)
2141
+
2142
+ if out or data_collection_endpoint:
2143
+ # convert results to something DCE understands, and add the inputs
2144
+ payload = []
2145
+ for key, value in results.items():
2146
+ value_with_key = value.copy()
2147
+ value_with_key["resource_type"] = value_with_key.pop("type")
2148
+ value_with_key["resource"] = key
2149
+ value_with_key["domain"] = domain
2150
+ value_with_key["substack"] = substack
2151
+ value_with_key["stage"] = stage
2152
+ value_with_key["subscription_id"] = azure_subscription_id
2153
+ payload.append(value_with_key)
2154
+
2155
+ if out:
2156
+ print(json.dumps(payload, indent=2))
2157
+
2158
+ if data_collection_endpoint:
2159
+ self._post_to_dce(
2160
+ data_collection_endpoint=data_collection_endpoint,
2161
+ payload=payload,
2162
+ )
2163
+
2164
+ return exit_code
2165
+ except Exception as e:
2166
+ self.printer.normal(f'Exception occurred: {e}')
2167
+
2168
+ if self.printer.print_verbose:
2169
+ traceback.print_exc()
2170
+
2171
+ return -1