Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/pysonar_scanner/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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/)."
)


Expand Down
51 changes: 31 additions & 20 deletions src/pysonar_scanner/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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:
"""
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/pysonar_scanner/configuration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/pysonar_scanner/configuration/configuration_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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]
51 changes: 48 additions & 3 deletions src/pysonar_scanner/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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
8 changes: 4 additions & 4 deletions src/pysonar_scanner/jre.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/pysonar_scanner/scannerengine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions tests/its/test_minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 21 additions & 2 deletions tests/unit/test_configuration_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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))
Loading