From fe4c1a40bfd52717df6a257fabfdbf06e10540e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 10:46:08 +0200 Subject: [PATCH 1/6] add configurable logging with --verbose and persistent run.log --- main.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index e352d7c..7c98217 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import os import getpass from rich.console import Console +from rich.logging import RichHandler from datetime import datetime from botocore.exceptions import NoCredentialsError, ProfileNotFound from azure.identity import DefaultAzureCredential, ClientSecretCredential @@ -53,20 +54,58 @@ from utils import codes from utils.version import __version__ -# Configure the root logger to ensure logs propagate from all modules -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logging.getLogger("botocore").setLevel(logging.WARNING) -logging.getLogger("boto3").setLevel(logging.WARNING) - -# Configure the logger +# Configure the logger (level is left to the handlers, see configure_logging) logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) # Initialize the console object console = Console() +# Third-party loggers kept quiet unless the user opts into deep verbosity (-vv) +_THIRD_PARTY_NOISY = ("botocore", "boto3", "azure") + +# Loggers with no diagnostic value ever — pinned to WARNING regardless of verbosity, +# so they never flood the -vv console or run.log. +_ALWAYS_QUIET = ("PIL",) + +def configure_logging(verbose: int = 0) -> None: + """ + verbose == 0 -> WARNING (default; the Rich step UI is the primary output) + verbose == 1 -> INFO (--verbose: show our interaction narrative) + verbose >= 2 -> DEBUG (-vv: also un-mute third-party libraries) + """ + root = logging.getLogger() + root.setLevel(logging.DEBUG) + root.handlers.clear() + + if verbose == 0: + console_level = logging.WARNING + elif verbose == 1: + console_level = logging.INFO + else: + console_level = logging.DEBUG + + console_handler = RichHandler( + console=console, show_path=False, rich_tracebacks=True + ) + console_handler.setLevel(console_level) + root.addHandler(console_handler) + + third_party_level = logging.DEBUG if verbose >= 2 else logging.WARNING + for name in _THIRD_PARTY_NOISY: + logging.getLogger(name).setLevel(third_party_level) + + for name in _ALWAYS_QUIET: + logging.getLogger(name).setLevel(logging.WARNING) + + +def add_run_log_handler(report_path: str) -> None: + file_handler = logging.FileHandler(os.path.join(report_path, "run.log")) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logging.getLogger().addHandler(file_handler) + class ConfigError(Exception): pass @@ -448,6 +487,7 @@ def run_assessment( # Create directories try: report_path, raw_data_path = create_directory() + add_run_log_handler(report_path) print_step("Directory successfully created.", status="ok") except RuntimeError as e: print_step("Directory creation failed.", status="error", logs=str(e)) @@ -791,8 +831,20 @@ def parse_arguments(): dest="cloud_provider", help="Specify the cloud provider (aws or azure)." ) + # Shared options available on every subcommand (e.g. `aws --verbose`) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase log verbosity (-v for INFO, -vv for DEBUG + third-party).", + ) + # Subparser for AWS - aws_parser = subparsers.add_parser("aws", help="Perform an AWS assessment.") + aws_parser = subparsers.add_parser( + "aws", parents=[common], help="Perform an AWS assessment." + ) aws_group = aws_parser.add_mutually_exclusive_group(required=False) aws_group.add_argument( "--config", type=str, help="Path to the configuration file (JSON format)." @@ -827,7 +879,9 @@ def parse_arguments(): ) # Subparser for Azure - azure_parser = subparsers.add_parser("azure", help="Perform an Azure assessment.") + azure_parser = subparsers.add_parser( + "azure", parents=[common], help="Perform an Azure assessment." + ) azure_group = azure_parser.add_mutually_exclusive_group(required=False) azure_group.add_argument( "--config", type=str, help="Path to the configuration file (JSON format)." @@ -870,6 +924,9 @@ def main(): # (ASCII art, dataset download). args = parse_arguments() + # Configure logging before any side effects so verbosity applies everywhere. + configure_logging(getattr(args, "verbose", 0)) + # Print ASCII art console.print(ascii_art, style="bold cyan") From 1fd8daec84539104a357a47bf9655d79777727a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 12:21:35 +0200 Subject: [PATCH 2/6] re-level logging and stop per-module loggers swallowing DEBUG --- core/engine.py | 6 ++++-- core/utils_azure.py | 4 +++- core/utils_db.py | 13 ++++++------- core/utils_report.py | 1 - core/utils_report_egress.py | 1 - core/utils_report_html.py | 1 - core/utils_report_json.py | 1 - core/utils_report_pdf.py | 1 - core/utils_sync.py | 1 - utils/azure.py | 4 ++-- utils/connection.py | 6 ++++-- utils/sync.py | 6 +++--- utils/utils.py | 4 ++-- 13 files changed, 24 insertions(+), 25 deletions(-) diff --git a/core/engine.py b/core/engine.py index cf246d5..6249fd1 100644 --- a/core/engine.py +++ b/core/engine.py @@ -126,8 +126,9 @@ def test_permissions( logs = "Both Reader and Cost Management Reader roles validation failed." except ClientAuthenticationError as e: + # Expected outcome of a permission test; surfaced to the user via `logs`. logs = f"Azure credentials validation failed: {str(e)}" - logger.error(logs) + logger.warning(logs) except Exception as e: logs = f"Azure permission test failed: {str(e)}" logger.error(logs) @@ -231,8 +232,9 @@ def test_permissions( logs += f" Details: {details}" except NoCredentialsError as e: + # Expected outcome of a permission test; surfaced to the user via `logs`. logs = f"AWS credentials validation failed: {str(e)}" - logger.error(logs) + logger.warning(logs) except Exception as e: logs = f"AWS permission test failed: {str(e)}" logger.error(logs) diff --git a/core/utils_azure.py b/core/utils_azure.py index ab9855d..2820568 100644 --- a/core/utils_azure.py +++ b/core/utils_azure.py @@ -35,7 +35,9 @@ def is_resource_inventory_empty( # logger.info("Resources found in the resource group.") return False except AzureError as e: - logger.error( + # Re-raised to build_azure_resource_inventory, which logs at ERROR with + # the traceback; keep this at DEBUG to avoid double-logging. + logger.debug( f"Error checking Azure resource inventory: {str(e)}", exc_info=True ) raise diff --git a/core/utils_db.py b/core/utils_db.py index 1c2d1ed..1d18e49 100644 --- a/core/utils_db.py +++ b/core/utils_db.py @@ -2,9 +2,8 @@ import sqlite3 import logging -# Configure logger for database operations +# Configure logger for database operations (level left to the root handlers) logger = logging.getLogger("core.engine.db") -logger.setLevel(logging.INFO) # Default master database MASTER_DATABASE = "datasets/data.db" @@ -28,7 +27,7 @@ def connect(db_path=MASTER_DATABASE): conn = sqlite3.connect(db_path) return conn except sqlite3.Error as e: - logger.error(f"Error connecting to database: {e}") + logger.debug(f"Error connecting to database: {e}") raise @@ -44,7 +43,7 @@ def load_data(table_name, db_path=MASTER_DATABASE): conn.close() return [dict(zip(columns, row)) for row in rows] except sqlite3.Error as e: - logger.error(f"Error loading data from table '{table_name}': {e}") + logger.debug(f"Error loading data from table '{table_name}': {e}") raise @@ -58,7 +57,7 @@ def execute_query(query, params=None, db_path=MASTER_DATABASE): conn.close() return rowcount except sqlite3.Error as e: - logger.error(f"Error executing query: {e}") + logger.debug(f"Error executing query: {e}") raise @@ -72,7 +71,7 @@ def fetch_one(query, params=None, db_path=MASTER_DATABASE): conn.close() return dict(zip(columns, row)) if row else None except sqlite3.Error as e: - logger.error(f"Error fetching data: {e}") + logger.debug(f"Error fetching data: {e}") raise @@ -86,5 +85,5 @@ def fetch_all(query, params=None, db_path=MASTER_DATABASE): conn.close() return [dict(zip(columns, row)) for row in rows] except sqlite3.Error as e: - logger.error(f"Error fetching data: {e}") + logger.debug(f"Error fetching data: {e}") raise diff --git a/core/utils_report.py b/core/utils_report.py index d97821b..02937b0 100644 --- a/core/utils_report.py +++ b/core/utils_report.py @@ -47,7 +47,6 @@ # Configure logger logger = logging.getLogger("core.engine.report") -logger.setLevel(logging.INFO) def anonymize_string(s: str, num_visible: int = 4) -> str: diff --git a/core/utils_report_egress.py b/core/utils_report_egress.py index 1218275..a8b55a5 100644 --- a/core/utils_report_egress.py +++ b/core/utils_report_egress.py @@ -36,7 +36,6 @@ PDF_HEADER_TITLE = "EscapeCloud Community Edition - Data & Egress" logger = logging.getLogger("core.engine.report_egress") -logger.setLevel(logging.INFO) DEFAULT_PRICING_ZONE = "zone1" UNIT_DIVISORS = {"GB": 10**9, "GiB": 2**30} diff --git a/core/utils_report_html.py b/core/utils_report_html.py index 6c626c3..56fc1ba 100644 --- a/core/utils_report_html.py +++ b/core/utils_report_html.py @@ -10,7 +10,6 @@ # Configure logger logger = logging.getLogger("core.engine.report_html") -logger.setLevel(logging.INFO) def transform_cost_inventory_for_html( diff --git a/core/utils_report_json.py b/core/utils_report_json.py index c9ac4cd..93a7a7e 100644 --- a/core/utils_report_json.py +++ b/core/utils_report_json.py @@ -11,7 +11,6 @@ # Configure logger logger = logging.getLogger("core.engine.report_json") -logger.setLevel(logging.INFO) def transform_resource_inventory_for_json( diff --git a/core/utils_report_pdf.py b/core/utils_report_pdf.py index 2e4f365..48d86a1 100644 --- a/core/utils_report_pdf.py +++ b/core/utils_report_pdf.py @@ -26,7 +26,6 @@ # Configure logger logger = logging.getLogger("core.engine.report_pdf") -logger.setLevel(logging.INFO) def transform_resource_inventory_for_pdf( diff --git a/core/utils_sync.py b/core/utils_sync.py index bb9db3c..177f4f6 100644 --- a/core/utils_sync.py +++ b/core/utils_sync.py @@ -14,7 +14,6 @@ # Configure logger logger = logging.getLogger("core.engine.sync") -logger.setLevel(logging.INFO) _ASSESS_PATH = "/api/v1/assessments/" diff --git a/utils/azure.py b/utils/azure.py index 3564522..95ead3d 100644 --- a/utils/azure.py +++ b/utils/azure.py @@ -54,7 +54,7 @@ def select_subscription(subscriptions: list[Any]) -> Any: # logger.info(f"Subscription selected: {selected_subscription.display_name} ({selected_subscription.subscription_id})") return selected_subscription except ValueError as e: - logger.warning(f"Invalid subscription selection: {e}") + logger.debug(f"Invalid subscription selection: {e}") console.print(f"[red]{e} Please select a valid number.[/red]") @@ -72,5 +72,5 @@ def select_resource_group(resource_groups: list[Any]) -> str: # logger.info(f"Resource Group selected: {selected_resource_group}") return selected_resource_group except ValueError as e: - logger.warning(f"Invalid resource group selection: {e}") + logger.debug(f"Invalid resource group selection: {e}") console.print(f"[red]{e} Please select a valid number.[/red]") diff --git a/utils/connection.py b/utils/connection.py index d8fbfc5..bb2f373 100644 --- a/utils/connection.py +++ b/utils/connection.py @@ -66,9 +66,11 @@ def get_jwt_token( "Authentication succeeded but token field missing in response: %s", data ) except requests.RequestException as exc: - logger.error("EscapeCloud authentication request failed: %s", exc) + logger.error("EscapeCloud authentication request failed: %s", exc, exc_info=True) except ValueError: - logger.error("EscapeCloud authentication response was not valid JSON.") + logger.error( + "EscapeCloud authentication response was not valid JSON.", exc_info=True + ) return None diff --git a/utils/sync.py b/utils/sync.py index cf3819a..609c058 100644 --- a/utils/sync.py +++ b/utils/sync.py @@ -30,12 +30,12 @@ def submit_assessment( ) -> requests.Response | None: host = host or getattr(config, "HOST", "") if config else "" if not host: - logger.warning("HOST not configured – skipping assessment sync.") + logger.debug("HOST not configured – skipping assessment sync.") return None token = get_jwt_token(host=host, key=key) if key else get_jwt_token(host=host) if not token: - logger.warning("Could not obtain JWT – skipping assessment sync.") + logger.debug("Could not obtain JWT – skipping assessment sync.") return None url = _build_url(host) @@ -49,5 +49,5 @@ def submit_assessment( logger.info("POST %s – status %s", url, resp.status_code) return resp except requests.RequestException as exc: - logger.error("Assessment POST failed: %s", exc) + logger.error("Assessment POST failed: %s", exc, exc_info=True) return None diff --git a/utils/utils.py b/utils/utils.py index 8bf553c..0a87386 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -63,7 +63,7 @@ def prompt_required_inputs() -> tuple[int, int]: # logger.info(f"Exit Strategy selected: {exit_strategy}") break except ValueError as e: - logger.warning(f"Invalid exit strategy input: {e}") + logger.debug(f"Invalid exit strategy input: {e}") console.print(f"[red]{e} Please enter 1 or 3.[/red]") while True: @@ -78,7 +78,7 @@ def prompt_required_inputs() -> tuple[int, int]: # logger.info(f"Assessment Type selected: {assessment_type}") break except ValueError as e: - logger.warning(f"Invalid assessment type input: {e}") + logger.debug(f"Invalid assessment type input: {e}") console.print(f"[red]{e} Please enter 1 or 2.[/red]") return exit_strategy, assessment_type From 30ec8e3d46645d42bf81336232d88f3697824c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 12:36:49 +0200 Subject: [PATCH 3/6] fix run.log handler aborting the run when its dir is unavailable --- main.py | 18 ++++++++++++------ utils/connection.py | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 7c98217..4a554b3 100644 --- a/main.py +++ b/main.py @@ -67,6 +67,7 @@ # so they never flood the -vv console or run.log. _ALWAYS_QUIET = ("PIL",) + def configure_logging(verbose: int = 0) -> None: """ verbose == 0 -> WARNING (default; the Rich step UI is the primary output) @@ -99,12 +100,17 @@ def configure_logging(verbose: int = 0) -> None: def add_run_log_handler(report_path: str) -> None: - file_handler = logging.FileHandler(os.path.join(report_path, "run.log")) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter( - logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - ) - logging.getLogger().addHandler(file_handler) + # A logging-setup failure must never abort the assessment, so degrade + # gracefully if run.log cannot be created (e.g. dir missing or read-only). + try: + file_handler = logging.FileHandler(os.path.join(report_path, "run.log")) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logging.getLogger().addHandler(file_handler) + except OSError as exc: + logger.warning("Could not create run.log in %s: %s", report_path, exc) class ConfigError(Exception): diff --git a/utils/connection.py b/utils/connection.py index bb2f373..6dd1f26 100644 --- a/utils/connection.py +++ b/utils/connection.py @@ -66,7 +66,9 @@ def get_jwt_token( "Authentication succeeded but token field missing in response: %s", data ) except requests.RequestException as exc: - logger.error("EscapeCloud authentication request failed: %s", exc, exc_info=True) + logger.error( + "EscapeCloud authentication request failed: %s", exc, exc_info=True + ) except ValueError: logger.error( "EscapeCloud authentication response was not valid JSON.", exc_info=True From f5012b6db915349b39079d2774633b3e16910a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 13:00:27 +0200 Subject: [PATCH 4/6] make failures diagnosable: write tracebacks, return from sync_assessment --- core/engine.py | 21 ++++++++++++++++++--- core/utils_aws.py | 10 +++++++++- main.py | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/core/engine.py b/core/engine.py index 6249fd1..1d0b4d1 100644 --- a/core/engine.py +++ b/core/engine.py @@ -323,7 +323,12 @@ def sync_assessment( ) if not result.get("success"): - raise RuntimeError(f"Assessment sync failed: {result.get('logs')}") + return { + "success": False, + "online": True, + "payload": None, + "logs": f"Assessment sync failed: {result.get('logs')}", + } logger.debug(result) @@ -355,7 +360,12 @@ def sync_assessment( except Exception as e: logger.error("Error saving server risks to local DB: %s", str(e), exc_info=True) - raise RuntimeError(f"Failed to store server risks: {str(e)}") + return { + "success": False, + "online": True, + "payload": None, + "logs": f"Failed to store server risks: {str(e)}", + } try: scoring = payload.get("scoring_data") @@ -380,7 +390,12 @@ def sync_assessment( except Exception as e: logger.error("Error saving scoring data to local DB: %s", str(e), exc_info=True) - raise RuntimeError(f"Failed to store scoring data: {str(e)}") + return { + "success": False, + "online": True, + "payload": None, + "logs": f"Failed to store scoring data: {str(e)}", + } return result diff --git a/core/utils_aws.py b/core/utils_aws.py index e37bfd4..cff6479 100644 --- a/core/utils_aws.py +++ b/core/utils_aws.py @@ -127,7 +127,15 @@ def build_aws_resource_inventory( ) except Exception: - # logger.error(f"Error while processing {service_name}", exc_info=True) + # Log and skip: one throttled/failing service must not abort the + # whole inventory, but it must not look identical to an empty one. + logger.error( + "Error processing %s.%s in %s", + service_name, + operation_name, + region, + exc_info=True, + ) continue # Save raw data to a JSON file diff --git a/main.py b/main.py index 4a554b3..9d41111 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ import sys import os import getpass +import traceback from rich.console import Console from rich.logging import RichHandler from datetime import datetime @@ -464,6 +465,10 @@ def run_assessment( # Record the assessment start time to propagate across stages started_at = int(time.time()) + # Bound up front so the error handler can reference it even if the crash + # happens before the report directory is created. + report_path = None + try: # Preliminary Stage: Validate configuration & create directory console.print("-------------------------------------------") @@ -802,6 +807,20 @@ def run_assessment( except Exception as e: console.print(f"[red]Unexpected error: {e}[/red]") + # Persist the full traceback so a one-line error is actionable. Falls + # back to the cwd when the crash happened before the report dir existed. + target_dir = report_path or os.getcwd() + try: + log_file = os.path.join(target_dir, f"error-{int(time.time())}.log") + with open(log_file, "w", encoding="utf-8") as fh: + fh.write(traceback.format_exc()) + console.print(f"[yellow]Full traceback written to: {log_file}[/yellow]") + except OSError: + console.print( + "[yellow]Could not write a traceback file; see run.log.[/yellow]" + ) + # Also funnel to run.log at DEBUG (kept off the default console). + logger.debug("Unexpected error", exc_info=True) sys.exit(codes.UNEXPECTED) From f900c2f6361b70397a0a214f31cedbacc60dc482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 13:59:18 +0200 Subject: [PATCH 5/6] log AWS per-service failures at DEBUG to keep them off the console & tests for failure diagnosability --- core/utils_aws.py | 15 +++++--- tests/test_engine.py | 56 +++++++++++++++++++++++++++- tests/test_utils_and_main.py | 54 +++++++++++++++++++++++++++ tests/test_utils_aws.py | 71 +++++++++++++++++++++++++++++++++--- 4 files changed, 183 insertions(+), 13 deletions(-) diff --git a/core/utils_aws.py b/core/utils_aws.py index cff6479..be68639 100644 --- a/core/utils_aws.py +++ b/core/utils_aws.py @@ -126,15 +126,18 @@ def build_aws_resource_inventory( } ) - except Exception: - # Log and skip: one throttled/failing service must not abort the - # whole inventory, but it must not look identical to an empty one. - logger.error( - "Error processing %s.%s in %s", + except Exception as exc: + # Expected for services the caller can't access or that aren't + # available in a region. Keep at DEBUG (run.log only) so it never + # floods the console, while still distinguishing a failed service + # from an empty one. The message alone is the useful signal; the + # botocore stack is noise, so no exc_info here. + logger.debug( + "Error processing %s.%s in %s: %s", service_name, operation_name, region, - exc_info=True, + exc, ) continue diff --git a/tests/test_engine.py b/tests/test_engine.py index 963dc89..e4e46e1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import MagicMock, patch -from core.engine import test_permissions +from core.engine import sync_assessment, test_permissions class TestPermissionsAwsHybridMode(unittest.TestCase): @@ -105,5 +105,59 @@ def client_side_effect(service_name, **kwargs): self.assertIn("ce:GetCostAndUsage failed", logs) +class SyncAssessmentContractTests(unittest.TestCase): + def test_offline_returns_success_without_calling_the_api(self): + with patch("core.engine.post_assessment") as mock_post: + result = sync_assessment( + report_path="/tmp", + name="n", + started_at=0, + metadata={}, + mode="offline", + token=None, + ) + self.assertTrue(result["success"]) + self.assertFalse(result["online"]) + mock_post.assert_not_called() + + def test_server_failure_returns_dict_not_raises(self): + with patch( + "core.engine.post_assessment", + return_value={"success": False, "payload": None, "logs": "401"}, + ): + result = sync_assessment( + report_path="/tmp", + name="n", + started_at=0, + metadata={}, + mode="online", + token="tok", + ) + self.assertFalse(result["success"]) + self.assertIn("401", result["logs"]) + + def test_local_db_failure_returns_dict_not_raises(self): + good = { + "success": True, + "payload": { + "data": {"risk_inventory": [{"id": 1, "impacted_resources": []}]} + }, + } + with ( + patch("core.engine.post_assessment", return_value=good), + patch("core.engine.connect", side_effect=Exception("db down")), + ): + result = sync_assessment( + report_path="/tmp", + name="n", + started_at=0, + metadata={}, + mode="online", + token="tok", + ) + self.assertFalse(result["success"]) + self.assertIn("store server risks", result["logs"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils_and_main.py b/tests/test_utils_and_main.py index ecda437..0f8371a 100644 --- a/tests/test_utils_and_main.py +++ b/tests/test_utils_and_main.py @@ -264,6 +264,60 @@ def test_unexpected_exception_exits_1(self): main.run_assessment(VALID_CONFIG.copy(), "aws") self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + def test_unexpected_error_writes_traceback_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + raw_data_path = os.path.join(tmp_dir, "raw") + os.makedirs(raw_data_path, exist_ok=True) + with self.assertRaises(SystemExit) as ctx: + with ( + patch("main.validate_config"), + patch("main.resolve_mode", return_value=("offline", None)), + patch( + "main.create_directory", + return_value=(tmp_dir, raw_data_path), + ), + patch( + "main.verify_credentials", + side_effect=RuntimeError("boom-unexpected"), + ), + patch("main.print_step"), + patch("main.console.print"), + ): + main.run_assessment(VALID_CONFIG.copy(), "aws") + + self.assertEqual(ctx.exception.code, codes.UNEXPECTED) + logs = [f for f in os.listdir(tmp_dir) if f.startswith("error-")] + self.assertEqual(len(logs), 1) + body = Path(tmp_dir, logs[0]).read_text(encoding="utf-8") + self.assertIn("Traceback", body) + self.assertIn("boom-unexpected", body) + + def test_online_sync_failure_exits_risk_assessment(self): + with self.assertRaises(SystemExit) as ctx: + with ( + patch("main.validate_config"), + patch("main.resolve_mode", return_value=("online", "jwt-token")), + patch("main.create_directory", return_value=("/tmp/r", "/tmp/r/raw")), + patch("main.verify_credentials", return_value=(True, "ok")), + patch("main.test_permissions", return_value=(True, True, True, "ok")), + patch( + "main.create_resource_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "main.create_cost_inventory", + return_value={"success": True, "logs": ""}, + ), + patch( + "main.sync_assessment", + return_value={"success": False, "logs": "server responded 401"}, + ), + patch("main.print_step"), + patch("main.console.print"), + ): + main.run_assessment(VALID_CONFIG.copy(), "aws") + self.assertEqual(ctx.exception.code, codes.RISK_ASSESSMENT) + def test_full_success_exits_0(self): with ( patch("main.validate_config"), diff --git a/tests/test_utils_aws.py b/tests/test_utils_aws.py index 3c3eb50..5e5dfed 100644 --- a/tests/test_utils_aws.py +++ b/tests/test_utils_aws.py @@ -1,4 +1,5 @@ # tests/test_utils_aws.py +import logging import os import tempfile import unittest @@ -206,6 +207,69 @@ def test_outer_exception_is_logged_silently(self, mock_session_cls, mock_load_da ) +class BuildAwsResourceInventoryPerServiceTests(unittest.TestCase): + @patch("core.utils_aws.connect") + @patch("core.utils_aws.paginate_or_call") + @patch("core.utils_aws.boto3.Session") + @patch("core.utils_aws.load_data") + def test_failed_service_is_skipped_and_logged_at_debug( + self, mock_load_data, mock_session_cls, mock_poc, mock_connect + ): + mock_load_data.return_value = [ + { + "code": "AWS.ec2.describe_instances.Reservations", + "id": 1, + "name": "EC2", + "csp": 2, + "status": "t", + }, + { + "code": "AWS.s3.list_buckets.Buckets", + "id": 2, + "name": "S3", + "csp": 2, + "status": "t", + }, + ] + # First service raises (e.g. AccessDenied); second returns resources. + mock_poc.side_effect = [ + botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDenied", "Message": "no"}}, + "DescribeInstances", + ), + [{"InstanceId": "i-1"}], + ] + mock_connect.return_value.__enter__.return_value = MagicMock() + + from core.utils_aws import build_aws_resource_inventory + + with tempfile.TemporaryDirectory() as tmp: + report_path = os.path.join(tmp, "report") + raw_data_path = os.path.join(tmp, "raw") + os.makedirs(os.path.join(report_path, "data"), exist_ok=True) + os.makedirs(raw_data_path, exist_ok=True) + + with self.assertLogs("core.engine.aws", level="DEBUG") as cm: + build_aws_resource_inventory( + 2, + {"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"}, + report_path, + raw_data_path, + ) + + # Loop continued past the failing service to the next one. + self.assertEqual(mock_poc.call_count, 2) + # The failure was recorded at DEBUG, naming the failed service... + self.assertTrue( + any( + r.levelno == logging.DEBUG and "ec2" in r.getMessage() + for r in cm.records + ) + ) + # ...and never escalated to WARNING/ERROR (stays off the console). + self.assertFalse(any(r.levelno >= logging.WARNING for r in cm.records)) + + class PaginateTests(unittest.TestCase): def _fake_client(self, pages): """Build a stub client whose paginator yields the given pages.""" @@ -233,12 +297,7 @@ def test_forwards_kwargs_to_paginator(self): ) def test_throttling_mid_pagination_does_not_silently_truncate(self): - """Regression: retrying next() on a dead PageIterator generator - used to return a truncated prefix as if it were the full result. - With retries pushed down to the client (AWS_RETRY_CONFIG), the - paginator generator never sees the throttle and delivers every page. - Here we assert paginate() does not swallow a mid-iteration error.""" - + def flaky_pages(): yield {"Items": ["r1", "r2"]} raise botocore.exceptions.ClientError( From 52fb369bd1dce150f874c8df82bc92d1573cd4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Mon, 27 Jul 2026 14:02:50 +0200 Subject: [PATCH 6/6] tests for failure diagnosability - formatting --- tests/test_utils_aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils_aws.py b/tests/test_utils_aws.py index 5e5dfed..8ccf9de 100644 --- a/tests/test_utils_aws.py +++ b/tests/test_utils_aws.py @@ -297,7 +297,7 @@ def test_forwards_kwargs_to_paginator(self): ) def test_throttling_mid_pagination_does_not_silently_truncate(self): - + def flaky_pages(): yield {"Items": ["r1", "r2"]} raise botocore.exceptions.ClientError(