Skip to content

Commit d9236d9

Browse files
authored
SCANPY-166 Ensure the JRE and scanner engine JAR is downloaded properly when the US region is set (#187)
1 parent 5aacd2c commit d9236d9

File tree

8 files changed

+180
-18
lines changed

8 files changed

+180
-18
lines changed

src/pysonar_scanner/api.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
GLOBAL_SONARCLOUD_URL = "https://sonarcloud.io"
4242
US_SONARCLOUD_URL = "https://sonarqube.us"
4343

44+
UNAUTHORIZED_STATUS_CODES = (401, 403)
45+
46+
ACCEPT_JSON = {"Accept": "application/json"}
47+
ACCEPT_OCTET_STREAM = {"Accept": "application/octet-stream"}
48+
4449

4550
@dataclass(frozen=True)
4651
class SQVersion:
@@ -92,7 +97,7 @@ def __post_init__(self):
9297

9398
@dataclass(frozen=True)
9499
class JRE:
95-
id: str
100+
id: Optional[str]
96101
filename: str
97102
sha256: str
98103
java_path: str
@@ -103,7 +108,7 @@ class JRE:
103108
@staticmethod
104109
def from_dict(dict: dict) -> "JRE":
105110
return JRE(
106-
id=dict["id"],
111+
id=dict.get("id", None),
107112
filename=dict["filename"],
108113
sha256=dict["sha256"],
109114
java_path=dict["javaPath"],
@@ -182,6 +187,7 @@ def __call__(self, r):
182187
class EngineInfo:
183188
filename: str
184189
sha256: str
190+
download_url: Optional[str] = None
185191

186192

187193
class SonarQubeApi:
@@ -193,7 +199,7 @@ def __raise_exception(self, exception: Exception) -> NoReturn:
193199
if (
194200
isinstance(exception, requests.RequestException)
195201
and exception.response is not None
196-
and exception.response.status_code == 401
202+
and exception.response.status_code in UNAUTHORIZED_STATUS_CODES
197203
):
198204
raise SonarQubeApiUnauthroizedException.create_default(self.base_urls.base_url) from exception
199205
else:
@@ -215,14 +221,14 @@ def get_analysis_version(self) -> SQVersion:
215221

216222
def get_analysis_engine(self) -> EngineInfo:
217223
try:
218-
res = requests.get(
219-
f"{self.base_urls.api_base_url}/analysis/engine", headers={"Accept": "application/json"}, auth=self.auth
220-
)
224+
res = requests.get(f"{self.base_urls.api_base_url}/analysis/engine", headers=ACCEPT_JSON, auth=self.auth)
221225
res.raise_for_status()
222226
json = res.json()
223227
if "filename" not in json or "sha256" not in json:
224228
raise SonarQubeApiException("Invalid response from the server")
225-
return EngineInfo(filename=json["filename"], sha256=json["sha256"])
229+
return EngineInfo(
230+
filename=json["filename"], sha256=json["sha256"], download_url=json.get("downloadUrl", None)
231+
)
226232
except requests.RequestException as e:
227233
self.__raise_exception(e)
228234

@@ -234,7 +240,7 @@ def download_analysis_engine(self, handle: typing.BinaryIO) -> None:
234240
try:
235241
res = requests.get(
236242
f"{self.base_urls.api_base_url}/analysis/engine",
237-
headers={"Accept": "application/octet-stream"},
243+
headers=ACCEPT_OCTET_STREAM,
238244
auth=self.auth,
239245
)
240246
self.__download_file(res, handle)
@@ -247,7 +253,7 @@ def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]:
247253
res = requests.get(
248254
f"{self.base_urls.api_base_url}/analysis/jres",
249255
auth=self.auth,
250-
headers={"Accept": "application/json"},
256+
headers=ACCEPT_JSON,
251257
params=params,
252258
)
253259
res.raise_for_status()
@@ -265,13 +271,27 @@ def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None:
265271
try:
266272
res = requests.get(
267273
f"{self.base_urls.api_base_url}/analysis/jres/{id}",
268-
headers={"Accept": "application/octet-stream"},
274+
headers=ACCEPT_OCTET_STREAM,
269275
auth=self.auth,
270276
)
271277
self.__download_file(res, handle)
272278
except requests.RequestException as e:
273279
self.__raise_exception(e)
274280

281+
def download_file_from_url(self, url: str, handle: typing.BinaryIO) -> None:
282+
"""
283+
This method can raise a SonarQubeApiException if the server doesn't respond successfully.
284+
Alternative, if the file IO fails, an IOError or OSError can be raised.
285+
"""
286+
try:
287+
res = requests.get(
288+
url,
289+
headers=ACCEPT_OCTET_STREAM,
290+
)
291+
self.__download_file(res, handle)
292+
except requests.RequestException as e:
293+
self.__raise_exception(e)
294+
275295
def __download_file(self, res: requests.Response, handle: typing.BinaryIO) -> None:
276296
res.raise_for_status()
277297
for chunk in res.iter_content(chunk_size=128):

src/pysonar_scanner/jre.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,14 @@ def __download_jre(self, jre: JRE) -> Optional[pathlib.Path]:
102102
cache_file.filepath.unlink(missing_ok=True)
103103

104104
with cache_file.open(mode="wb") as f:
105-
self.api.download_analysis_jre(jre.id, f)
105+
if jre.download_url is not None:
106+
self.api.download_file_from_url(jre.download_url, f)
107+
elif jre.id is not None:
108+
self.api.download_analysis_jre(jre.id, f)
109+
else:
110+
raise JreProvisioningException(
111+
"Failed to download the JRE using SonarQube. If this problem persists, you can use the option --sonar-scanner-java-exe-path to use your own local JRE."
112+
)
106113

107114
return cache_file.filepath if cache_file.is_valid() else None
108115

src/pysonar_scanner/scannerengine.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from threading import Thread
2626
from typing import IO, Callable, Optional
2727

28-
from pysonar_scanner.api import SonarQubeApi
28+
from pysonar_scanner.api import EngineInfo, SonarQubeApi
2929
from pysonar_scanner.cache import Cache, CacheFile
3030
from pysonar_scanner.exceptions import ChecksumException
3131
from pysonar_scanner.jre import JREResolvedPath
@@ -127,12 +127,15 @@ def __download_and_verify(self) -> Optional[CacheFile]:
127127
cache_file = self.cache.get_file(engine_info.filename, engine_info.sha256)
128128
if not cache_file.is_valid():
129129
logging.debug("No valid cached analysis engine jar was found")
130-
self.__download_scanner_engine(cache_file)
130+
self.__download_scanner_engine(cache_file, engine_info)
131131
return cache_file if cache_file.is_valid() else None
132132

133-
def __download_scanner_engine(self, cache_file: CacheFile) -> None:
133+
def __download_scanner_engine(self, cache_file: CacheFile, engine_info: EngineInfo) -> None:
134134
with cache_file.open(mode="wb") as f:
135-
self.api.download_analysis_engine(f)
135+
if engine_info.download_url is not None:
136+
self.api.download_file_from_url(engine_info.download_url, f)
137+
else:
138+
self.api.download_analysis_engine(f)
136139

137140

138141
class ScannerEngine:

tests/its/test_minimal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929

3030
def test_minimal_project(sonarqube_client: SonarQubeClient, cli: CliClient):
31-
process = cli.run_analysis(sources_dir="minimal")
31+
process = cli.run_analysis(sources_dir="minimal", params=["--verbose"])
3232
assert process.returncode == 0, str(process.stdout)
3333

3434
data = sonarqube_client.get_project_issues("minimal")

tests/unit/sq_api_utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,20 @@ def mock_analysis_version(self, version: str = "", status: int = 200) -> respons
5252
return self.rsps.get(url=f"{self.api_url}/analysis/version", body=version, status=status)
5353

5454
def mock_analysis_engine(
55-
self, filename: Optional[str] = None, sha256: Optional[str] = None, status: int = 200
55+
self,
56+
filename: Optional[str] = None,
57+
sha256: Optional[str] = None,
58+
download_url: Optional[str] = None,
59+
status: int = 200,
5660
) -> responses.BaseResponse:
5761
def prepare_json_obj() -> dict:
5862
json_response = {}
5963
if filename:
6064
json_response["filename"] = filename
6165
if sha256:
6266
json_response["sha256"] = sha256
67+
if download_url:
68+
json_response["downloadUrl"] = download_url
6369
return json_response
6470

6571
return self.rsps.get(
@@ -105,6 +111,22 @@ def mock_analysis_jre_download(
105111
match=[matchers.header_matcher({"Accept": "application/octet-stream"})],
106112
)
107113

114+
def mock_download_url(
115+
self,
116+
url: str,
117+
body: bytes = b"",
118+
status: int = 200,
119+
redirect_url: Optional[str] = None,
120+
) -> responses.BaseResponse:
121+
122+
return self.rsps.get(
123+
url=url,
124+
body=body,
125+
headers={"Location": redirect_url} if redirect_url else None,
126+
status=status,
127+
match=[matchers.header_matcher({"Accept": "application/octet-stream"})],
128+
)
129+
108130
def mock_server_version(self, version: str = "", status: int = 200) -> responses.BaseResponse:
109131
return self.rsps.get(url=f"{self.base_url}/api/server/version", body=version, status=status)
110132

tests/unit/test_api.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ def test_get_analysis_engine(self):
300300
mocker.mock_analysis_engine(filename=engine_info.filename, sha256=engine_info.sha256)
301301
self.assertEqual(self.sq.get_analysis_engine(), engine_info)
302302

303+
with self.subTest("get_analysis_engine with downloadUrl"), sq_api_mocker() as mocker:
304+
engine_info = EngineInfo(
305+
filename="sonar-scanner-engine-shaded-8.9.0.43852-all.jar",
306+
sha256="1234567890",
307+
download_url="https://example.com",
308+
)
309+
mocker.mock_analysis_engine(
310+
filename=engine_info.filename, sha256=engine_info.sha256, download_url="https://example.com"
311+
)
312+
self.assertEqual(self.sq.get_analysis_engine(), engine_info)
313+
303314
with (
304315
self.subTest("get_analysis_engine returns error"),
305316
sq_api_mocker() as mocker,
@@ -362,6 +373,15 @@ def test_get_analysis_jres(self):
362373
arch="x64",
363374
download_url=None,
364375
),
376+
JRE(
377+
id=None,
378+
filename="jre3.tar.gz",
379+
sha256="dummysha256value3",
380+
java_path="/path/to/jre1/bin/java",
381+
os="linux",
382+
arch="x64",
383+
download_url="https://example.com/jre3.tar.gz",
384+
),
365385
]
366386

367387
with self.subTest("get_analysis_jres works (linux)"), sq_api_mocker() as mocker:
@@ -431,6 +451,33 @@ def test_download_analysis_jre(self):
431451
# since the api is not mocked, requests will throw an exception
432452
self.sq.download_analysis_jre(jre_id, io.BytesIO())
433453

454+
def test_download_file_from_url(self):
455+
jre_url = "https://sonarcloud.io/jres/OpenJDK17U-jre_x64_alpine-linux_hotspot_17.0.11_9.tar.gz"
456+
jre_file_content = b"fake_jre_binary"
457+
with self.subTest("download_jre_from_url without auth works"), sq_api_mocker() as mocker:
458+
mocker.mock_download_url(url=jre_url, body=jre_file_content)
459+
fake_file = io.BytesIO()
460+
461+
self.sq.download_file_from_url(jre_url, fake_file)
462+
463+
self.assertEqual(fake_file.getvalue(), jre_file_content)
464+
465+
with (
466+
self.subTest("download_jre_from_url returns 404"),
467+
sq_api_mocker() as mocker,
468+
self.assertRaises(SonarQubeApiException),
469+
):
470+
mocker.mock_download_url(url=jre_url, status=404)
471+
self.sq.download_file_from_url(jre_url, io.BytesIO())
472+
473+
with (
474+
self.subTest("download_jre_from_url: requests throws exception"),
475+
sq_api_mocker() as mocker,
476+
self.assertRaises(SonarQubeApiException),
477+
):
478+
# since the api is not mocked, requests will throw an exception
479+
self.sq.download_file_from_url(jre_url, io.BytesIO())
480+
434481
def test_to_api_configuration(self):
435482
with self.subTest("Missing keys"):
436483
expected = {

tests/unit/test_jre.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io
2121
import pathlib
2222
import tarfile
23+
from typing import cast
2324
from unittest.mock import Mock, patch
2425
from typing_extensions import TypedDict
2526
import unittest
@@ -32,7 +33,12 @@
3233
SONAR_SCANNER_OS,
3334
SONAR_SCANNER_SKIP_JRE_PROVISIONING,
3435
)
35-
from pysonar_scanner.exceptions import ChecksumException, NoJreAvailableException, UnsupportedArchiveFormat
36+
from pysonar_scanner.exceptions import (
37+
ChecksumException,
38+
JreProvisioningException,
39+
NoJreAvailableException,
40+
UnsupportedArchiveFormat,
41+
)
3642
from pysonar_scanner.jre import JREProvisioner, JREResolvedPath, JREResolver, JREResolverConfiguration
3743
from pysonar_scanner.utils import Os, Arch
3844
from tests.unit import sq_api_utils
@@ -80,6 +86,16 @@ def __setup_zip_file(self):
8086
download_url=None,
8187
)
8288

89+
self.zip_jre_with_download_url = JRE(
90+
id="zip_jre",
91+
filename=self.zip_name,
92+
sha256=self.zip_checksum,
93+
java_path="java",
94+
os=Os.LINUX.value,
95+
arch=Arch.AARCH64.value,
96+
download_url="https://scanner.sonarqube.us/jres/OpenJDK17U-jre_x64_alpine-linux_hotspot_17.0.11_9.tar.gz",
97+
)
98+
8399
def __setup_tar_file(self):
84100
buffer = io.BytesIO()
85101
with tarfile.open(fileobj=buffer, mode="w:gz") as tar_file:
@@ -154,6 +170,41 @@ class JRETestCase(TypedDict):
154170
self.assertTrue((unziped_dir / "readme.md").exists())
155171
self.assertEqual((unziped_dir / "readme.md").read_bytes(), b"hello world")
156172

173+
def test_download_jre_with_download_url(self, get_os_mock, get_arch_mock):
174+
jre = self.zip_jre_with_download_url
175+
with sq_api_utils.sq_api_mocker() as mocker:
176+
mocker.mock_analysis_jres(body=[sq_api_utils.jre_to_dict(jre)])
177+
mocker.mock_download_url(url=cast(str, jre.download_url), body=self.zip_bytes, status=200)
178+
179+
provisioner = JREProvisioner(self.api, self.cache, utils.get_os().value, utils.get_arch().value)
180+
jre_path = provisioner.provision()
181+
182+
cache_file = self.cache.get_file(jre.filename, self.zip_checksum)
183+
self.assertTrue(cache_file.is_valid())
184+
185+
unziped_dir = self.cache.get_file_path("jre.zip_unzip")
186+
self.assertEqual(jre_path, JREResolvedPath(unziped_dir / "java"))
187+
188+
self.assertTrue(unziped_dir.exists())
189+
self.assertTrue((unziped_dir / "readme.md").exists())
190+
self.assertEqual((unziped_dir / "readme.md").read_bytes(), b"hello world")
191+
192+
def test_downloaind_jre_with_neither_download_url_nor_id(self, *args):
193+
jre = JRE(
194+
id=None,
195+
filename="jre.zip",
196+
sha256=self.zip_checksum,
197+
java_path="java",
198+
os=Os.LINUX.value,
199+
arch=Arch.AARCH64.value,
200+
download_url=None,
201+
)
202+
with self.assertRaises(JreProvisioningException), sq_api_utils.sq_api_mocker() as mocker:
203+
mocker.mock_analysis_jres(body=[sq_api_utils.jre_to_dict(jre)])
204+
205+
provisioner = JREProvisioner(self.api, self.cache, utils.get_os().value, utils.get_arch().value)
206+
provisioner.provision()
207+
157208
def test_invalid_checksum(self, *args):
158209
with self.assertRaises(ChecksumException), sq_api_utils.sq_api_mocker() as mocker:
159210
jre_dict = sq_api_utils.jre_to_dict(self.zip_jre)

tests/unit/test_scannerengine.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ def test_happy_path(self):
183183
self.assertTrue(self.test_file_path.exists())
184184
self.assertEqual(self.test_file_path.read_bytes(), self.test_file_content)
185185

186+
def test_happy_path_with_download_url(self):
187+
with sq_api_utils.sq_api_mocker() as mocker:
188+
mocker.mock_analysis_engine(
189+
filename="scanner-engine.jar", sha256=self.test_file_checksum, download_url="http://example.com"
190+
)
191+
mocker.mock_download_url(url="http://example.com", body=self.test_file_content)
192+
193+
ScannerEngineProvisioner(self.api, self.cache).provision()
194+
195+
self.assertTrue(self.test_file_path.exists())
196+
self.assertEqual(self.test_file_path.read_bytes(), self.test_file_content)
197+
186198
def test_scanner_engine_is_cached(self):
187199
with sq_api_utils.sq_api_mocker(assert_all_requests_are_fired=False) as mocker:
188200
engine_info_rsps = mocker.mock_analysis_engine(

0 commit comments

Comments
 (0)