diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 31c35854..0e047dd3 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -20,6 +20,7 @@ from pysonar_scanner import app_logging from pysonar_scanner import cache +from pysonar_scanner import exceptions from pysonar_scanner.api import get_base_urls, SonarQubeApi, BaseUrls, MIN_SUPPORTED_SQ_VERSION from pysonar_scanner.configuration import configuration_loader from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader @@ -39,11 +40,20 @@ def scan(): + try: + return do_scan() + except Exception as e: + return exceptions.log_error(e) + + +def do_scan(): app_logging.setup() config = ConfigurationLoader.load() set_logging_options(config) + ConfigurationLoader.check_configuration(config) + api = build_api(config) check_version(api) update_config_with_api_urls(config, api.base_urls) @@ -64,13 +74,15 @@ def build_api(config: dict[str, any]) -> SonarQubeApi: return SonarQubeApi(base_urls, token) -def check_version(api): +def check_version(api: SonarQubeApi): if api.is_sonar_qube_cloud(): return version = api.get_analysis_version() if not version.does_support_bootstrapping(): raise SQTooOldException( - f"Only SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}" + f"This scanner only supports SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION}. \n" + f"The server at {api.base_urls.base_url} is on version {version}\n" + "Please either upgrade your SonarQube server or use the Sonar Scanner CLI (see https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/scanners/sonarscanner/)." ) diff --git a/src/pysonar_scanner/api.py b/src/pysonar_scanner/api.py index e13de8f9..5b8184eb 100644 --- a/src/pysonar_scanner/api.py +++ b/src/pysonar_scanner/api.py @@ -19,7 +19,7 @@ # import typing from dataclasses import dataclass -from typing import Optional, TypedDict +from typing import NoReturn, Optional, TypedDict import requests import requests.auth @@ -31,8 +31,12 @@ SONAR_REGION, Key, ) -from pysonar_scanner.exceptions import MissingKeyException, SonarQubeApiException, InconsistentConfiguration from pysonar_scanner.utils import remove_trailing_slash, OsStr, ArchStr +from pysonar_scanner.exceptions import ( + SonarQubeApiException, + InconsistentConfiguration, + SonarQubeApiUnauthroizedException, +) GLOBAL_SONARCLOUD_URL = "https://sonarcloud.io" US_SONARCLOUD_URL = "https://sonarqube.us" @@ -98,18 +102,15 @@ class JRE: @staticmethod def from_dict(dict: dict) -> "JRE": - try: - return JRE( - id=dict["id"], - filename=dict["filename"], - sha256=dict["sha256"], - java_path=dict["javaPath"], - os=dict["os"], - arch=dict["arch"], - download_url=dict.get("downloadUrl", None), - ) - except KeyError as e: - raise MissingKeyException(f"Missing key in dictionary {dict}") from e + return JRE( + id=dict["id"], + filename=dict["filename"], + sha256=dict["sha256"], + java_path=dict["javaPath"], + os=dict["os"], + arch=dict["arch"], + download_url=dict.get("downloadUrl", None), + ) ApiConfiguration = TypedDict( @@ -188,6 +189,16 @@ def __init__(self, base_urls: BaseUrls, token: str): self.base_urls = base_urls self.auth = BearerAuth(token) + def __raise_exception(self, exception: Exception) -> NoReturn: + if ( + isinstance(exception, requests.RequestException) + and exception.response is not None + and exception.response.status_code == 401 + ): + raise SonarQubeApiUnauthroizedException.create_default(self.base_urls.base_url) from exception + else: + raise SonarQubeApiException("Error while fetching the analysis version") from exception + def is_sonar_qube_cloud(self) -> bool: return self.base_urls.is_sonar_qube_cloud @@ -200,7 +211,7 @@ def get_analysis_version(self) -> SQVersion: res.raise_for_status() return SQVersion.from_str(res.text) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis version") from e + self.__raise_exception(e) def get_analysis_engine(self) -> EngineInfo: try: @@ -213,7 +224,7 @@ def get_analysis_engine(self) -> EngineInfo: raise SonarQubeApiException("Invalid response from the server") return EngineInfo(filename=json["filename"], sha256=json["sha256"]) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis engine information") from e + self.__raise_exception(e) def download_analysis_engine(self, handle: typing.BinaryIO) -> None: """ @@ -229,7 +240,7 @@ def download_analysis_engine(self, handle: typing.BinaryIO) -> None: ) self.__download_file(res, handle) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis engine") from e + self.__raise_exception(e) def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]: try: @@ -243,8 +254,8 @@ def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]: res.raise_for_status() json_array = res.json() return [JRE.from_dict(jre) for jre in json_array] - except (requests.RequestException, MissingKeyException) as e: - raise SonarQubeApiException("Error while fetching the analysis version") from e + except (requests.RequestException, KeyError) as e: + self.__raise_exception(e) def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None: """ @@ -260,7 +271,7 @@ def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None: ) self.__download_file(res, handle) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the JRE") from e + self.__raise_exception(e) def __download_file(self, res: requests.Response, handle: typing.BinaryIO) -> None: res.raise_for_status() diff --git a/src/pysonar_scanner/configuration/cli.py b/src/pysonar_scanner/configuration/cli.py index b9dcc918..9948b910 100644 --- a/src/pysonar_scanner/configuration/cli.py +++ b/src/pysonar_scanner/configuration/cli.py @@ -37,7 +37,7 @@ def load(cls) -> dict[str, any]: # Handle unknown args starting with '-D' for arg in unknown_args: if not arg.startswith("-D"): - raise UnexpectedCliArgument(f"Unexpected argument: {arg}") + raise UnexpectedCliArgument(f"Unexpected argument: {arg}\nRun with --help for more information.") key_value = arg[2:].split("=", 1) if len(key_value) == 2: key, value = key_value diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index bf3a0130..d0207d51 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -21,11 +21,11 @@ from pysonar_scanner.configuration.cli import CliConfigurationLoader from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader -from pysonar_scanner.configuration.properties import SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key +from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key from pysonar_scanner.configuration.properties import PROPERTIES from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader -from pysonar_scanner.exceptions import MissingKeyException +from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException def get_static_default_properties() -> dict[Key, any]: @@ -56,8 +56,20 @@ def load() -> dict[Key, any]: resolved_properties.update(cli_properties) return resolved_properties + @staticmethod + def check_configuration(config: dict[Key, any]) -> None: + missing_keys = [] + if SONAR_TOKEN not in config: + missing_keys.append(MissingProperty(SONAR_TOKEN, "--token")) + + if SONAR_PROJECT_KEY not in config: + missing_keys.append(MissingProperty(SONAR_PROJECT_KEY, "--project-key")) + + if len(missing_keys) > 0: + raise MissingPropertyException.from_missing_keys(*missing_keys) + def get_token(config: dict[Key, any]) -> str: if SONAR_TOKEN not in config: - raise MissingKeyException(f'Missing property "{SONAR_TOKEN}"') + raise MissingPropertyException(f'Missing property "{SONAR_TOKEN}"') return config[SONAR_TOKEN] diff --git a/src/pysonar_scanner/exceptions.py b/src/pysonar_scanner/exceptions.py index b19f3291..d81cbaa2 100644 --- a/src/pysonar_scanner/exceptions.py +++ b/src/pysonar_scanner/exceptions.py @@ -19,14 +19,44 @@ # -class MissingKeyException(Exception): - pass +from dataclasses import dataclass +import logging + +EXCEPTION_RETURN_CODE = 1 + + +@dataclass +class MissingProperty: + property: str + cli_arg: str + + +class MissingPropertyException(Exception): + @staticmethod + def from_missing_keys(*properties: MissingProperty) -> "MissingPropertyException": + missing_properties = ", ".join([f"{prop.property} ({prop.cli_arg})" for prop in properties]) + fix_message = ( + "You can provide these properties using one of the following methods:\n" + "- Command line arguments (e.g., --sonar.projectKey=myproject)\n" + "- Environment variables (e.g., SONAR_PROJECTKEY=myproject)\n" + "- Properties file (sonar-project.properties)\n" + "- Project configuration files (e.g., build.gradle, pom.xml)" + ) + return MissingPropertyException(f"Missing required properties: {missing_properties}\n\n{fix_message}") class SonarQubeApiException(Exception): pass +class SonarQubeApiUnauthroizedException(SonarQubeApiException): + @staticmethod + def create_default(server_url: str) -> "SonarQubeApiUnauthroizedException": + return SonarQubeApiUnauthroizedException( + f'The provided token is invalid for the server at "{server_url}". Please check that both the token and the server URL are correct.' + ) + + class SQTooOldException(Exception): pass @@ -36,7 +66,9 @@ class InconsistentConfiguration(Exception): class ChecksumException(Exception): - pass + @staticmethod + def create(what: str) -> "ChecksumException": + return ChecksumException(f"Checksum mismatch. The downloaded {what} is corrupted.") class UnexpectedCliArgument(Exception): @@ -53,3 +85,16 @@ class NoJreAvailableException(JreProvisioningException): class UnsupportedArchiveFormat(JreProvisioningException): pass + + +def log_error(e: Exception): + logger = logging.getLogger() + is_debug_level = logger.getEffectiveLevel() <= logging.DEBUG + + if is_debug_level: + logger.error("The following exception occured while running the analysis", exc_info=True) + else: + logger.error(str(e), exc_info=False) + logger.info("For more details, please enable debug logging by passing the --verbose option.") + + return EXCEPTION_RETURN_CODE diff --git a/src/pysonar_scanner/jre.py b/src/pysonar_scanner/jre.py index 12d31e70..6e00ecef 100644 --- a/src/pysonar_scanner/jre.py +++ b/src/pysonar_scanner/jre.py @@ -69,9 +69,7 @@ def __attempt_provisioning_jre_with_retry(self) -> tuple[JRE, pathlib.Path]: if jre_and_resolved_path is None: jre_and_resolved_path = self.__attempt_provisioning_jre() if jre_and_resolved_path is None: - raise ChecksumException( - f"Failed to download and verify JRE for {self.sonar_scanner_os} and {self.sonar_scanner_arch}" - ) + raise ChecksumException.create("JRE") return jre_and_resolved_path @@ -129,7 +127,9 @@ def __extract_jre(self, file_path: pathlib.Path, unzip_dir: pathlib.Path): with tarfile.open(file_path, "r:gz") as tar_ref: tar_ref.extractall(unzip_dir, filter="data") else: - raise UnsupportedArchiveFormat(f"Unsupported archive format: {file_path.suffix}") + raise UnsupportedArchiveFormat( + f"Received JRE is packaged as an unsupported archive format: {file_path.suffix}" + ) @dataclass(frozen=True) diff --git a/src/pysonar_scanner/scannerengine.py b/src/pysonar_scanner/scannerengine.py index d6787412..5f786adc 100644 --- a/src/pysonar_scanner/scannerengine.py +++ b/src/pysonar_scanner/scannerengine.py @@ -119,7 +119,7 @@ def provision(self) -> pathlib.Path: if scanner_file is not None: return scanner_file.filepath else: - raise ChecksumException("Failed to download and verify scanner engine") + raise ChecksumException.create("scanner engine JAR") def __download_and_verify(self) -> Optional[CacheFile]: engine_info = self.api.get_analysis_engine() diff --git a/tests/its/test_minimal.py b/tests/its/test_minimal.py index 28ab7ccd..b3befa06 100644 --- a/tests/its/test_minimal.py +++ b/tests/its/test_minimal.py @@ -46,6 +46,6 @@ def test_minimal_project_unexpected_arg(cli: CliClient): def test_invalid_token(sonarqube_client: SonarQubeClient, cli: CliClient): - process = cli.run_analysis(sources_dir="minimal", token="invalid") + process = cli.run_analysis(params=["--verbose"], sources_dir="minimal", token="invalid") assert process.returncode == 1, str(process.stdout) - assert "401 Client Error" in process.stdout + assert "HTTPError: 401 Client Error" in process.stdout diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index bc317cd4..c2545057 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -51,8 +51,9 @@ SONAR_SCANNER_ARCH, SONAR_SCANNER_OS, ) -from pysonar_scanner.exceptions import MissingKeyException from pysonar_scanner.utils import Arch, Os +from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR +from pysonar_scanner.exceptions import MissingPropertyException # Mock utils.get_os and utils.get_arch at the module level @@ -116,7 +117,7 @@ def test_get_token(self, mock_get_os, mock_get_arch): with self.subTest("Token is present"): self.assertEqual(configuration_loader.get_token({SONAR_TOKEN: "myToken"}), "myToken") - with self.subTest("Token is absent"), self.assertRaises(MissingKeyException): + with self.subTest("Token is absent"), self.assertRaises(MissingPropertyException): configuration_loader.get_token({}) @patch("sys.argv", ["myscript.py", "--token", "myToken", "--sonar-project-key", "myProjectKey"]) @@ -428,3 +429,21 @@ def test_unknown_args_with_D_prefix(self, mock_get_os, mock_get_arch): self.assertEqual(configuration["another.unknown.property"], "anotherValue") self.assertEqual(configuration[SONAR_TOKEN], "myToken") self.assertEqual(configuration[SONAR_PROJECT_KEY], "myProjectKey") + + def test_check_configuration(self, mock_get_os, mock_get_arch): + with self.subTest("Both values present"): + ConfigurationLoader.check_configuration({SONAR_TOKEN: "", SONAR_PROJECT_KEY: ""}) + + with self.subTest("missing keys"): + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({SONAR_PROJECT_KEY: "myKey"}) + self.assertIn(SONAR_TOKEN, str(cm.exception)) + + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({SONAR_TOKEN: "myToken"}) + self.assertIn(SONAR_PROJECT_KEY, str(cm.exception)) + + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({}) + self.assertIn(SONAR_PROJECT_KEY, str(cm.exception)) + self.assertIn(SONAR_TOKEN, str(cm.exception)) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..c4f65f5b --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,57 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2024 SonarSource SA. +# mailto:info AT sonarsource DOT com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +import pytest +import unittest +import logging +from pysonar_scanner.exceptions import log_error, EXCEPTION_RETURN_CODE + + +class TestExceptions(unittest.TestCase): + @pytest.fixture(autouse=True) + def set_caplog(self, caplog: pytest.LogCaptureFixture): + self.caplog = caplog + + def test_log_error_returns_exception_return_code(self): + exception = Exception("Test exception") + result = log_error(exception) + self.assertEqual(result, EXCEPTION_RETURN_CODE) + + def setUp(self) -> None: + self.caplog.clear() + + def test_log_error_logs_message(self): + # Test that log_error logs the exception message + exception = Exception("Test exception") + with self.caplog.at_level(logging.ERROR): + log_error(exception) + + self.assertIn("Test exception", self.caplog.text) + self.assertNotIn("Traceback", self.caplog.text) + + def test_log_error_includes_stack_trace_in_debug_mode(self): + # raises an exception to get an Exception object with a strace trace + try: + raise Exception("Test exception") + except Exception as exception: + with self.caplog.at_level(logging.DEBUG): + log_error(exception) + + self.assertIn("Test exception", self.caplog.text) + self.assertIn("Traceback", self.caplog.text) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 63ae9069..c2deff85 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -84,6 +84,13 @@ def test_minimal_success_run(self, run_mock, create_jre_mock, provision_mock, lo self.assertEqual(expected_config, config) + @patch.object(ConfigurationLoader, "load") + def test_scan_with_exception(self, load_mock): + load_mock.side_effect = Exception("Test exception") + + exitcode = scan() + self.assertEqual(1, exitcode) + def test_version_check_outdated_sonarqube(self): sq_cloud_api = sq_api_utils.get_sq_server() sq_cloud_api.get_analysis_version = Mock(return_value=SQVersion.from_str("9.9.9"))