Skip to content

Commit e932208

Browse files
SCANPY-102 New bootstrapping: Execute the Scan (#148)
1 parent 4825ca2 commit e932208

File tree

5 files changed

+174
-13
lines changed

5 files changed

+174
-13
lines changed

src/pysonar_scanner/__main__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
2020

21+
from pysonar_scanner import cache
22+
from pysonar_scanner.configuration import Configuration, ConfigurationLoader
23+
from pysonar_scanner.api import get_base_urls, SonarQubeApi
24+
from pysonar_scanner.scannerengine import ScannerEngine
25+
from pysonar_scanner.cache import Cache
26+
from pysonar_scanner.jre import JREProvisioner, JREResolvedPath, JREResolver
27+
2128

2229
def scan():
23-
pass
30+
configuration = ConfigurationLoader().initialize_configuration()
31+
cache_manager = cache.get_default()
32+
api = __build_api(configuration)
33+
scanner = ScannerEngine(api, cache_manager)
34+
return scanner.run(configuration)
35+
36+
37+
def __build_api(configuration) -> SonarQubeApi:
38+
base_urls = get_base_urls(configuration)
39+
return SonarQubeApi(base_urls, configuration.sonar.token)

src/pysonar_scanner/scannerengine.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,42 @@
1717
# along with this program; if not, write to the Free Software Foundation,
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
20+
import json
21+
import pathlib
22+
from typing import Optional
23+
2024
import pysonar_scanner.api as api
25+
2126
from pysonar_scanner.api import SonarQubeApi
2227
from pysonar_scanner.cache import Cache, CacheFile
2328
from pysonar_scanner.exceptions import ChecksumException, SQTooOldException
29+
from pysonar_scanner.configuration import Configuration
30+
from pysonar_scanner.jre import JREProvisioner, JREResolvedPath, JREResolver
31+
from subprocess import Popen, PIPE
2432

2533

2634
class ScannerEngineProvisioner:
2735
def __init__(self, api: SonarQubeApi, cache: Cache):
2836
self.api = api
2937
self.cache = cache
3038

31-
def provision(self) -> None:
32-
if self.__download_and_verify():
33-
return
39+
def provision(self) -> pathlib.Path:
40+
scanner_file = self.__download_and_verify()
41+
if scanner_file is not None:
42+
return scanner_file.filepath
3443
# Retry once in case the checksum failed due to the scanner engine being updated between getting the checksum and downloading the jar
35-
if self.__download_and_verify():
36-
return
44+
scanner_file = self.__download_and_verify()
45+
if scanner_file is not None:
46+
return scanner_file.filepath
3747
else:
3848
raise ChecksumException("Failed to download and verify scanner engine")
3949

40-
def __download_and_verify(self) -> bool:
50+
def __download_and_verify(self) -> Optional[CacheFile]:
4151
engine_info = self.api.get_analysis_engine()
4252
cache_file = self.cache.get_file(engine_info.filename, engine_info.sha256)
4353
if not cache_file.is_valid():
4454
self.__download_scanner_engine(cache_file)
45-
return cache_file.is_valid()
55+
return cache_file if cache_file.is_valid() else None
4656

4757
def __download_scanner_engine(self, cache_file: CacheFile) -> None:
4858
with cache_file.open(mode="wb") as f:
@@ -54,6 +64,46 @@ def __init__(self, api: SonarQubeApi, cache: Cache):
5464
self.api = api
5565
self.cache = cache
5666

67+
def __fetch_scanner_engine(self) -> pathlib.Path:
68+
return ScannerEngineProvisioner(self.api, self.cache).provision()
69+
70+
def run(self, configuration: Configuration):
71+
self.__version_check()
72+
jre_path = self.__resolve_jre(configuration)
73+
scanner_engine_path = self.__fetch_scanner_engine()
74+
cmd = self.__build_command(jre_path, scanner_engine_path)
75+
return self.__execute_scanner_engine(configuration, cmd)
76+
77+
def __build_command(self, jre_path: JREResolvedPath, scanner_engine_path: pathlib.Path) -> list[str]:
78+
cmd = []
79+
cmd.append(jre_path.path)
80+
cmd.append("-jar")
81+
cmd.append(scanner_engine_path)
82+
return cmd
83+
84+
def __execute_scanner_engine(self, configuration: Configuration, cmd: list[str]) -> int:
85+
popen = Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE)
86+
outs, _ = popen.communicate(configuration.to_json().encode())
87+
exitcode = popen.wait() # 0 means success
88+
if exitcode != 0:
89+
errors = self.__extract_errors_from_log(outs)
90+
raise RuntimeError(f"Scan failed with exit code {exitcode}", errors)
91+
return exitcode
92+
93+
def __extract_errors_from_log(self, outs: str) -> list[str]:
94+
try:
95+
errors = []
96+
for line in outs.decode("utf-8").split("\n"):
97+
if line.strip() == "":
98+
continue
99+
out_json = json.loads(line)
100+
if out_json["level"] == "ERROR":
101+
errors.append(out_json["message"])
102+
return errors
103+
except Exception as e:
104+
print(e)
105+
return []
106+
57107
def __version_check(self):
58108
if self.api.is_sonar_qube_cloud():
59109
return
@@ -63,5 +113,7 @@ def __version_check(self):
63113
f"Only SonarQube versions >= {api.MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}"
64114
)
65115

66-
def __fetch_scanner_engine(self):
67-
ScannerEngineProvisioner(self.api, self.cache).provision()
116+
def __resolve_jre(self, configuration: Configuration) -> JREResolvedPath:
117+
jre_provisionner = JREProvisioner(self.api, self.cache)
118+
jre_resolver = JREResolver(configuration, jre_provisionner)
119+
return jre_resolver.resolve_jre()

src/pysonar_scanner/utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import hashlib
2121
import pathlib
2222
import platform
23-
import re
2423
import typing
2524
from enum import Enum
2625

tests/test_main.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#
2+
# Sonar Scanner Python
3+
# Copyright (C) 2011-2024 SonarSource SA.
4+
# mailto:info AT sonarsource DOT com
5+
#
6+
# This program is free software; you can redistribute it and/or
7+
# modify it under the terms of the GNU Lesser General Public
8+
# License as published by the Free Software Foundation; either
9+
# version 3 of the License, or (at your option) any later version.
10+
# This program is distributed in the hope that it will be useful,
11+
#
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
# Lesser General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Lesser General Public License
17+
# along with this program; if not, write to the Free Software Foundation,
18+
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
#
20+
import unittest
21+
from unittest import mock
22+
from unittest.mock import patch
23+
24+
from pysonar_scanner.jre import JRE, JREResolvedPath
25+
from pysonar_scanner.__main__ import scan
26+
from pysonar_scanner.scannerengine import ScannerEngine
27+
28+
29+
class TestMain(unittest.TestCase):
30+
31+
@patch("pysonar_scanner.scannerengine.Popen")
32+
@patch.object(ScannerEngine, "_ScannerEngine__resolve_jre")
33+
@patch.object(ScannerEngine, "_ScannerEngine__fetch_scanner_engine")
34+
@patch(
35+
"sys.argv",
36+
["pysonar-scanner", "-t", "myToken", "--sonar-project-key", "myProjectKey"],
37+
)
38+
def test_minimal_run_success(self, mock_fetch_scanner_engine, mock_resolve_jre, mock_popen):
39+
mock_resolve_jre.return_value = JREResolvedPath("")
40+
mock_fetch_scanner_engine.return_value = None
41+
process_mock = mock.Mock()
42+
attrs = {"communicate.return_value": ("output", "error"), "wait.return_value": 0}
43+
process_mock.configure_mock(**attrs)
44+
mock_popen.return_value = process_mock
45+
46+
exitcode = scan()
47+
self.assertEqual(exitcode, 0)
48+
49+
@patch("pysonar_scanner.scannerengine.Popen")
50+
@patch.object(ScannerEngine, "_ScannerEngine__resolve_jre")
51+
@patch.object(ScannerEngine, "_ScannerEngine__fetch_scanner_engine")
52+
@patch(
53+
"sys.argv",
54+
["pysonar-scanner", "-t", "myToken", "--sonar-project-key", "myProjectKey"],
55+
)
56+
def test_minimal_run_failure(self, mock_fetch_scanner_engine, mock_resolve_jre, mock_popen):
57+
mock_resolve_jre.return_value = JREResolvedPath("")
58+
process_mock = mock.Mock()
59+
mock_fetch_scanner_engine.return_value = None
60+
attrs = {"communicate.return_value": ("output", "error"), "wait.return_value": 2}
61+
process_mock.configure_mock(**attrs)
62+
mock_popen.return_value = process_mock
63+
with self.assertRaises(RuntimeError) as error:
64+
scan()
65+
print(str(error))
66+
self.assertIn("Scan failed with exit code 2", str(error.exception))

tests/test_scannerengine.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
# along with this program; if not, write to the Free Software Foundation,
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
20-
import pathlib
2120
import unittest
21+
import pathlib
2222
import pyfakefs.fake_filesystem_unittest as pyfakefs
2323

2424
from pysonar_scanner import cache
@@ -30,7 +30,35 @@
3030
from tests import sq_api_utils
3131

3232

33-
class TestScannerEngine(pyfakefs.TestCase):
33+
class TestScannerEngine(unittest.TestCase):
34+
35+
def test_error_log_extraction(self):
36+
log_failure_example = b"""{"level":"INFO","message":"CPD Executor CPD calculation finished (done) | time=15ms"}\n
37+
{"level":"INFO","message":"SCM revision ID \'a53e6a3193a049d0f77fc2ff16cf52e7a66c7adb\'"}\n
38+
{"level":"INFO","message":"Analysis report generated in 152ms, dir size=760.6 kB"}\n
39+
{"level":"INFO","message":"Analysis report compressed in 83ms, zip size=321.7 kB"}\n
40+
{"level":"ERROR","message":"You\'re not authorized to analyze this project or the project doesn\'t exist on SonarQube and you\'re not authorized to create it. Please contact an administrator."}\n"""
41+
42+
scanner = ScannerEngine(None, None)
43+
expected = "You're not authorized to analyze this project or the project doesn't exist on SonarQube and you're not authorized to create it. Please contact an administrator."
44+
errors = scanner._ScannerEngine__extract_errors_from_log(log_failure_example)
45+
print(errors)
46+
self.assertEqual(
47+
errors,
48+
[expected],
49+
)
50+
51+
def test_exception_in_error_log_extraction(self):
52+
unexpected_log_failure_example = b"""unexpected log format'"""
53+
scanner = ScannerEngine(None, None)
54+
errors = scanner._ScannerEngine__extract_errors_from_log(unexpected_log_failure_example)
55+
self.assertEqual(
56+
errors,
57+
[],
58+
)
59+
60+
61+
class TestScannerEngineWithFake(pyfakefs.TestCase):
3462
def setUp(self):
3563
self.setUpPyfakefs()
3664

0 commit comments

Comments
 (0)