diff --git a/CLI_ARGS.md b/CLI_ARGS.md index 933654d2..148f7a51 100644 --- a/CLI_ARGS.md +++ b/CLI_ARGS.md @@ -75,6 +75,7 @@ | `--sonar-scanner-arch`, `-Dsonar.scanner.arch` | Architecture on which the scanner will be running | | `--sonar-scanner-cloud-url`, `-Dsonar.scanner.cloudUrl` | SonarQube Cloud base URL, https://sonarcloud.io for example | | `--sonar-scanner-connect-timeout`, `-Dsonar.scanner.connectTimeout` | Time period to establish connections with the server (in seconds) | +| `--sonar-scanner-engine-jar-path`, `-Dsonar.scanner.engineJarPath` | Path to a local scanner engine JAR. If set, the scanner engine will not be downloaded from the server | | `--sonar-scanner-internal-dump-to-file`, `-Dsonar.scanner.internal.dumpToFile` | Filename where the input to the scanner engine will be dumped. Useful for debugging | | `--sonar-scanner-internal-sq-version`, `-Dsonar.scanner.internal.sqVersion` | Emulate the result of the call to get SQ server version. Useful for debugging with --sonar-scanner-internal-dump-to-file | | `--sonar-scanner-java-exe-path`, `-Dsonar.scanner.javaExePath` | If defined, the scanner engine will be run with this JRE | diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 44e3a46b..54899dfc 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -19,6 +19,8 @@ # import logging +import os +import pathlib from typing import Any from pysonar_scanner import app_logging from pysonar_scanner import cache @@ -36,6 +38,7 @@ SONAR_SCANNER_OS, SONAR_SCANNER_ARCH, SONAR_SCANNER_DRY_RUN, + SONAR_SCANNER_ENGINE_JAR_PATH, SONAR_PROJECT_BASE_DIR, SONAR_PYTHON_COVERAGE_REPORT_PATHS, ) @@ -116,14 +119,34 @@ def update_config_with_api_urls(config, base_urls: BaseUrls): def create_scanner_engine(api, cache_manager, config): + configured_scanner_engine_path = get_configured_scanner_engine_path(config) jre_path = create_jre(api, cache_manager, config) config[SONAR_SCANNER_JAVA_EXE_PATH] = str(jre_path.path) logging.debug(f"JRE path: {jre_path.path}") - scanner_engine_path = ScannerEngineProvisioner(api, cache_manager).provision() + scanner_engine_path = configured_scanner_engine_path or ScannerEngineProvisioner(api, cache_manager).provision() scanner = ScannerEngine(jre_path, scanner_engine_path) return scanner +def get_configured_scanner_engine_path(config: dict[str, Any]) -> pathlib.Path | None: + if not config.get(SONAR_SCANNER_ENGINE_JAR_PATH): + return None + + scanner_engine_path = pathlib.Path(config[SONAR_SCANNER_ENGINE_JAR_PATH]) + if not scanner_engine_path.is_file(): + raise exceptions.InconsistentConfiguration( + f"Configured scanner engine JAR does not exist or is not a file: {scanner_engine_path}. " + f"Please check the '{SONAR_SCANNER_ENGINE_JAR_PATH}' property." + ) + if not os.access(scanner_engine_path, os.R_OK): + raise exceptions.InconsistentConfiguration( + f"Configured scanner engine JAR is not readable: {scanner_engine_path}. " + f"Please check file permissions for the '{SONAR_SCANNER_ENGINE_JAR_PATH}' property." + ) + logging.debug(f"Using local scanner engine JAR: {scanner_engine_path}") + return scanner_engine_path + + def create_jre(api, cache, config: dict[str, Any]) -> JREResolvedPath: jre_provisioner = JREProvisioner(api, cache, config[SONAR_SCANNER_OS], config[SONAR_SCANNER_ARCH]) jre_resolver = JREResolver(JREResolverConfiguration.from_dict(config), jre_provisioner) diff --git a/src/pysonar_scanner/configuration/cli.py b/src/pysonar_scanner/configuration/cli.py index 9389b0f7..48daa1c8 100644 --- a/src/pysonar_scanner/configuration/cli.py +++ b/src/pysonar_scanner/configuration/cli.py @@ -408,6 +408,12 @@ def __create_parser(cls): type=str, help="Arguments specifies the heap size provided to the JVM when running the scanner", ) + jvm_group.add_argument( + "--sonar-scanner-engine-jar-path", + "-Dsonar.scanner.engineJarPath", + type=str, + help="Path to a local scanner engine JAR. If set, the scanner engine will not be downloaded from the server", + ) truststore_group = parser.add_argument_group("Truststore arguments") truststore_group.add_argument( diff --git a/src/pysonar_scanner/configuration/properties.py b/src/pysonar_scanner/configuration/properties.py index ac93a19c..2c3a12b3 100644 --- a/src/pysonar_scanner/configuration/properties.py +++ b/src/pysonar_scanner/configuration/properties.py @@ -57,6 +57,7 @@ SONAR_SCANNER_METADATA_FILEPATH: Key = "sonar.scanner.metadataFilePath" SONAR_SCANNER_JAVA_OPTS: Key = "sonar.scanner.javaOpts" SONAR_SCANNER_JAVA_HEAP_SIZE: Key = "sonar.scanner.javaHeapSize" +SONAR_SCANNER_ENGINE_JAR_PATH: Key = "sonar.scanner.engineJarPath" SONAR_PROJECT_BASE_DIR: Key = "sonar.projectBaseDir" SONAR_PROJECT_KEY: Key = "sonar.projectKey" SONAR_PROJECT_NAME: Key = "sonar.projectName" @@ -315,6 +316,11 @@ def env_variable_name(self) -> str: default_value=None, cli_getter=lambda args: args.sonar_scanner_java_heap_size ), + Property( + name=SONAR_SCANNER_ENGINE_JAR_PATH, + default_value=None, + cli_getter=lambda args: args.sonar_scanner_engine_jar_path + ), Property( name=SONAR_SCANNER_METADATA_FILEPATH, default_value=None, diff --git a/tests/test_environment_variables.py b/tests/test_environment_variables.py index 0d61ec03..799b4bf3 100644 --- a/tests/test_environment_variables.py +++ b/tests/test_environment_variables.py @@ -26,6 +26,7 @@ SONAR_HOST_URL, SONAR_REGION, SONAR_SCANNER_ARCH, + SONAR_SCANNER_ENGINE_JAR_PATH, SONAR_SCANNER_JAVA_OPTS, SONAR_SCANNER_OS, SONAR_TOKEN, @@ -51,6 +52,7 @@ def test__environment_variables(self): "SONAR_HOST_URL": "https://sonarqube.example.com", "SONAR_USER_HOME": "/custom/sonar/home", "SONAR_SCANNER_JAVA_OPTS": "-Xmx1024m -XX:MaxPermSize=256m", + "SONAR_SCANNER_ENGINE_JAR_PATH": "/path/to/scanner-engine.jar", "SONAR_REGION": "us", } with patch.dict("os.environ", env, clear=True): @@ -60,9 +62,10 @@ def test__environment_variables(self): SONAR_HOST_URL: "https://sonarqube.example.com", SONAR_USER_HOME: "/custom/sonar/home", SONAR_SCANNER_JAVA_OPTS: "-Xmx1024m -XX:MaxPermSize=256m", + SONAR_SCANNER_ENGINE_JAR_PATH: "/path/to/scanner-engine.jar", SONAR_REGION: "us", } - self.assertEqual(len(properties), 5) + self.assertEqual(len(properties), 6) self.assertDictEqual(properties, expected_properties) def test_irrelevant_environment_variables(self): diff --git a/tests/test_properties.py b/tests/test_properties.py index da3d35af..e6346cee 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -29,6 +29,7 @@ SONAR_SCANNER_SOCKET_TIMEOUT, SONAR_SCANNER_PROXY_PASSWORD, SONAR_SCANNER_INTERNAL_DUMP_TO_FILE, + SONAR_SCANNER_ENGINE_JAR_PATH, SONAR_PROJECT_KEY, SONAR_PROJECT_BASE_DIR, PROPERTIES, @@ -47,6 +48,7 @@ def test_python_name_conversion(self): (SONAR_SCANNER_APP_VERSION, "sonar.scanner.app-version"), (SONAR_SCANNER_SOCKET_TIMEOUT, "sonar.scanner.socket-timeout"), (SONAR_SCANNER_PROXY_PASSWORD, "sonar.scanner.proxy-password"), + (SONAR_SCANNER_ENGINE_JAR_PATH, "sonar.scanner.engine-jar-path"), # Complex properties (SONAR_SCANNER_INTERNAL_DUMP_TO_FILE, "sonar.scanner.internal.dump-to-file"), (SONAR_PROJECT_KEY, "sonar.project-key"), @@ -82,6 +84,7 @@ def test_env_variable_name_conversion(self): (SONAR_SCANNER_APP_VERSION, "SONAR_SCANNER_APP_VERSION"), (SONAR_SCANNER_SOCKET_TIMEOUT, "SONAR_SCANNER_SOCKET_TIMEOUT"), (SONAR_SCANNER_PROXY_PASSWORD, "SONAR_SCANNER_PROXY_PASSWORD"), + (SONAR_SCANNER_ENGINE_JAR_PATH, "SONAR_SCANNER_ENGINE_JAR_PATH"), # Complex properties (SONAR_SCANNER_INTERNAL_DUMP_TO_FILE, "SONAR_SCANNER_INTERNAL_DUMP_TO_FILE"), (SONAR_PROJECT_KEY, "SONAR_PROJECT_KEY"), diff --git a/tests/unit/test_configuration_cli.py b/tests/unit/test_configuration_cli.py index 781cde3b..45934e8b 100644 --- a/tests/unit/test_configuration_cli.py +++ b/tests/unit/test_configuration_cli.py @@ -36,6 +36,7 @@ SONAR_SCANNER_API_BASE_URL, SONAR_SCANNER_ARCH, SONAR_SCANNER_CONNECT_TIMEOUT, + SONAR_SCANNER_ENGINE_JAR_PATH, SONAR_SCANNER_INTERNAL_DUMP_TO_FILE, SONAR_SCANNER_INTERNAL_SQ_VERSION, SONAR_SCANNER_JAVA_EXE_PATH, @@ -130,6 +131,7 @@ SONAR_SCANNER_JAVA_EXE_PATH: "mySonarScannerJavaExePath", SONAR_SCANNER_JAVA_OPTS: "mySonarScannerJavaOpts", SONAR_SCANNER_JAVA_HEAP_SIZE: "8000Mb", + SONAR_SCANNER_ENGINE_JAR_PATH: "mySonarScannerEngineJarPath", SONAR_SCANNER_METADATA_FILEPATH: "myMetadataFilepath", SONAR_REGION: "us", SONAR_ORGANIZATION: "mySonarOrganization", @@ -343,6 +345,8 @@ def test_impossible_os_choice(self): "mySonarScannerJavaExePath", "--sonar-scanner-java-opts", "mySonarScannerJavaOpts", + "--sonar-scanner-engine-jar-path", + "mySonarScannerEngineJarPath", "--sonar-scanner-metadata-filepath", "myMetadataFilepath", "--sonar-scanner-internal-dump-to-file", @@ -480,6 +484,7 @@ def test_all_cli_args(self): "-Dsonar.scanner.skipJreProvisioning", "-Dsonar.scanner.javaExePath=mySonarScannerJavaExePath", "-Dsonar.scanner.javaOpts=mySonarScannerJavaOpts", + "-Dsonar.scanner.engineJarPath=mySonarScannerEngineJarPath", "-Dsonar.scanner.metadataFilepath=myMetadataFilepath", "-Dsonar.region=us", "-Dsonar.organization=mySonarOrganization", diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index eda65cb9..0871acd5 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -18,11 +18,12 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # import pathlib +import tempfile from unittest.mock import patch, Mock, call from pyfakefs import fake_filesystem_unittest as pyfakefs -from pysonar_scanner.__main__ import scan, main, check_version, create_jre +from pysonar_scanner.__main__ import scan, main, check_version, create_jre, create_scanner_engine from pysonar_scanner.api import SQVersion, SonarQubeApi from pysonar_scanner.cache import Cache from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader @@ -35,9 +36,10 @@ SONAR_SCANNER_PROXY_PORT, SONAR_SCANNER_OS, SONAR_SCANNER_ARCH, + SONAR_SCANNER_ENGINE_JAR_PATH, SONAR_SCANNER_JAVA_EXE_PATH, ) -from pysonar_scanner.exceptions import SQTooOldException +from pysonar_scanner.exceptions import InconsistentConfiguration, SQTooOldException from pysonar_scanner.jre import JREResolvedPath, JREResolver from pysonar_scanner.scannerengine import ScannerEngine, ScannerEngineProvisioner from tests.unit import sq_api_utils @@ -137,3 +139,34 @@ def test_get_jre(self, resolve_jre_mock, cmd_executor_mock): api = SonarQubeApi(Mock(), Mock()) cache = Cache(Mock()) create_jre(api, cache, {SONAR_SCANNER_OS: "linux", SONAR_SCANNER_ARCH: "x64"}) + + @patch.object(ScannerEngineProvisioner, "provision") + @patch("pysonar_scanner.__main__.create_jre", return_value=JREResolvedPath(pathlib.Path("jre/bin/java"))) + def test_create_scanner_engine_uses_local_engine_jar_path(self, create_jre_mock, provision_mock): + with tempfile.NamedTemporaryFile(suffix=".jar") as engine_jar: + config = { + SONAR_SCANNER_OS: "linux", + SONAR_SCANNER_ARCH: "x64", + SONAR_SCANNER_ENGINE_JAR_PATH: engine_jar.name, + } + + scanner = create_scanner_engine(Mock(), Mock(), config) + + self.assertEqual(scanner.scanner_engine_path, pathlib.Path(engine_jar.name)) + self.assertEqual(pathlib.Path(config[SONAR_SCANNER_JAVA_EXE_PATH]), pathlib.Path("jre/bin/java")) + provision_mock.assert_not_called() + + @patch.object(ScannerEngineProvisioner, "provision") + @patch("pysonar_scanner.__main__.create_jre") + def test_create_scanner_engine_fails_when_local_engine_jar_path_is_missing(self, create_jre_mock, provision_mock): + config = { + SONAR_SCANNER_OS: "linux", + SONAR_SCANNER_ARCH: "x64", + SONAR_SCANNER_ENGINE_JAR_PATH: "/path/to/missing-scanner-engine.jar", + } + + with self.assertRaisesRegex(InconsistentConfiguration, "Configured scanner engine JAR does not exist"): + create_scanner_engine(Mock(), Mock(), config) + + create_jre_mock.assert_not_called() + provision_mock.assert_not_called()