Skip to content

Commit d51c4a9

Browse files
joke1196Seppli11
authored andcommitted
SCANPY-100: New bootstrapping: Resolve the JRE to use (#132)
Co-authored-by: Sebastian Zumbrunn <[email protected]>
1 parent e6b8409 commit d51c4a9

File tree

11 files changed

+840
-18
lines changed

11 files changed

+840
-18
lines changed

src/pysonar_scanner/api.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
#
2020
import typing
2121
from dataclasses import dataclass
22+
from typing import Optional
2223

2324
import requests
2425
import requests.auth
2526

2627
from pysonar_scanner.configuration import Configuration
27-
from pysonar_scanner.exceptions import SonarQubeApiException
28-
from pysonar_scanner.utils import remove_trailing_slash
28+
from pysonar_scanner.exceptions import MissingKeyException, SonarQubeApiException
29+
from pysonar_scanner.utils import Arch, Os, remove_trailing_slash
2930

3031

3132
@dataclass(frozen=True)
@@ -76,6 +77,32 @@ def __post_init__(self):
7677
object.__setattr__(self, "api_base_url", remove_trailing_slash(self.api_base_url))
7778

7879

80+
@dataclass(frozen=True)
81+
class JRE:
82+
id: str
83+
filename: str
84+
sha256: str
85+
java_path: str
86+
os: str
87+
arch: str
88+
download_url: Optional[str]
89+
90+
@staticmethod
91+
def from_dict(dict: dict) -> "JRE":
92+
try:
93+
return JRE(
94+
id=dict["id"],
95+
filename=dict["filename"],
96+
sha256=dict["sha256"],
97+
java_path=dict["javaPath"],
98+
os=dict["os"],
99+
arch=dict["arch"],
100+
download_url=dict.get("downloadUrl", None),
101+
)
102+
except KeyError as e:
103+
raise MissingKeyException(f"Missing key in dictionary {dict}") from e
104+
105+
79106
def get_base_urls(config: Configuration) -> BaseUrls:
80107
def is_sq_cloud_url(sonar_host_url: str) -> bool:
81108
sq_cloud_url = config.sonar.scanner.sonarcloud_url.strip() or "https://sonarcloud.io"
@@ -151,21 +178,51 @@ def download_analysis_engine(self, handle: typing.BinaryIO) -> None:
151178
Alternative, if the file IO fails, an IOError or OSError can be raised.
152179
"""
153180

154-
def fetch_response():
181+
try:
155182
res = requests.get(
156183
f"{self.base_urls.api_base_url}/analysis/engine",
157184
headers={"Accept": "application/octet-stream"},
158185
auth=self.auth,
159186
)
187+
self.__download_file(res, handle)
188+
except requests.RequestException as e:
189+
raise SonarQubeApiException("Error while fetching the analysis engine") from e
190+
191+
def get_analysis_jres(self, os: Optional[Os] = None, arch: Optional[Arch] = None) -> list[JRE]:
192+
try:
193+
params = {
194+
"os": os.value if os else None,
195+
"arch": arch.value if arch else None,
196+
}
197+
res = requests.get(
198+
f"{self.base_urls.api_base_url}/analysis/jres",
199+
auth=self.auth,
200+
headers={"Accept": "application/json"},
201+
params=params,
202+
)
160203
res.raise_for_status()
161-
return res
204+
json_array = res.json()
205+
return [JRE.from_dict(jre) for jre in json_array]
206+
except (requests.RequestException, MissingKeyException) as e:
207+
raise SonarQubeApiException("Error while fetching the analysis version") from e
162208

163-
def download_file(handle: typing.BinaryIO, requests: requests.Response):
164-
for chunk in requests.iter_content(chunk_size=128):
165-
handle.write(chunk)
209+
def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None:
210+
"""
211+
This method can raise a SonarQubeApiException if the server doesn't respond successfully.
212+
Alternative, if the file IO fails, an IOError or OSError can be raised.
213+
"""
166214

167215
try:
168-
res = fetch_response()
169-
download_file(handle, res)
216+
res = requests.get(
217+
f"{self.base_urls.api_base_url}/analysis/jres/{id}",
218+
headers={"Accept": "application/octet-stream"},
219+
auth=self.auth,
220+
)
221+
self.__download_file(res, handle)
170222
except requests.RequestException as e:
171-
raise SonarQubeApiException("Error while fetching the analysis engine information") from e
223+
raise SonarQubeApiException("Error while fetching the JRE") from e
224+
225+
def __download_file(self, res: requests.Response, handle: typing.BinaryIO) -> None:
226+
res.raise_for_status()
227+
for chunk in res.iter_content(chunk_size=128):
228+
handle.write(chunk)

src/pysonar_scanner/cache.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@
2424

2525
from pysonar_scanner import utils
2626

27-
OpenBinaryMode = typing.Literal["rb", "xb"]
27+
OpenBinaryMode = typing.Literal["wb", "xb"]
2828

2929

3030
@dataclass(frozen=True)
3131
class CacheFile:
3232
filepath: pathlib.Path
3333
checksum: str
3434

35+
def exists(self) -> bool:
36+
return self.filepath.exists()
37+
3538
def is_valid(self) -> bool:
3639
try:
3740
with open(self.filepath, "rb") as f:
@@ -41,7 +44,7 @@ def is_valid(self) -> bool:
4144
except OSError:
4245
return False
4346

44-
def open(self, mode: OpenBinaryMode = "rb") -> typing.BinaryIO:
47+
def open(self, mode: OpenBinaryMode) -> typing.BinaryIO:
4548
return open(self.filepath, mode=mode)
4649

4750

@@ -55,6 +58,9 @@ def get_file(self, filename: str, checksum: str) -> CacheFile:
5558
path = self.cache_folder / filename
5659
return CacheFile(path, checksum)
5760

61+
def get_file_path(self, filename: str) -> pathlib.Path:
62+
return self.cache_folder / filename
63+
5864
@staticmethod
5965
def create_cache(cache_folder: pathlib.Path):
6066
if not cache_folder.exists():
@@ -63,4 +69,4 @@ def create_cache(cache_folder: pathlib.Path):
6369

6470

6571
def get_default() -> Cache:
66-
return Cache.create_cache(pathlib.Path.home() / ".sonar-scanner")
72+
return Cache.create_cache(pathlib.Path.home() / ".sonar-scanner/cache")

src/pysonar_scanner/exceptions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
#
2020

2121

22+
class MissingKeyException(Exception):
23+
pass
24+
25+
2226
class SonarQubeApiException(Exception):
2327
pass
2428

@@ -29,3 +33,15 @@ class SQTooOldException(Exception):
2933

3034
class ChecksumException(Exception):
3135
pass
36+
37+
38+
class JreProvisioningException(Exception):
39+
pass
40+
41+
42+
class NoJreAvailableException(JreProvisioningException):
43+
pass
44+
45+
46+
class UnsupportedArchiveFormat(JreProvisioningException):
47+
pass

src/pysonar_scanner/jre.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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 pathlib
21+
import shutil
22+
import tarfile
23+
import zipfile
24+
from dataclasses import dataclass
25+
from typing import Optional
26+
27+
from pysonar_scanner import utils
28+
from pysonar_scanner.api import JRE, SonarQubeApi
29+
from pysonar_scanner.cache import Cache
30+
from pysonar_scanner.configuration import Configuration
31+
from pysonar_scanner.exceptions import (
32+
ChecksumException,
33+
NoJreAvailableException,
34+
UnsupportedArchiveFormat,
35+
)
36+
from pysonar_scanner.exceptions import JreProvisioningException
37+
38+
39+
@dataclass(frozen=True)
40+
class JREResolvedPath:
41+
path: pathlib.Path
42+
43+
@staticmethod
44+
def from_string(path: str) -> "JREResolvedPath":
45+
if not path:
46+
raise ValueError("JRE path cannot be empty")
47+
return JREResolvedPath(pathlib.Path(path))
48+
49+
50+
class JREProvisioner:
51+
def __init__(self, api: SonarQubeApi, cache: Cache):
52+
self.api = api
53+
self.cache = cache
54+
55+
def provision(self) -> JREResolvedPath:
56+
jre, resolved_path = self.__attempt_provisioning_jre_with_retry()
57+
return self.__unpack_jre(jre, resolved_path)
58+
59+
def __attempt_provisioning_jre_with_retry(self) -> tuple[JRE, pathlib.Path]:
60+
jre_and_resolved_path = self.__attempt_provisioning_jre()
61+
if jre_and_resolved_path is None:
62+
jre_and_resolved_path = self.__attempt_provisioning_jre()
63+
if jre_and_resolved_path is None:
64+
raise ChecksumException(
65+
f"Failed to download and verify JRE for {utils.get_os().value} and {utils.get_arch().value}"
66+
)
67+
68+
return jre_and_resolved_path
69+
70+
def __attempt_provisioning_jre(self) -> Optional[tuple[JRE, pathlib.Path]]:
71+
jre = self.__get_available_jre()
72+
73+
jre_path = self.__get_jre_from_cache(jre)
74+
if jre_path is not None:
75+
return (jre, jre_path)
76+
77+
jre_path = self.__download_jre(jre)
78+
return (jre, jre_path) if jre_path is not None else None
79+
80+
def __get_available_jre(self) -> JRE:
81+
jres = self.api.get_analysis_jres(os=utils.get_os(), arch=utils.get_arch())
82+
if len(jres) == 0:
83+
raise NoJreAvailableException(
84+
f"No JREs are available for {utils.get_os().value} and {utils.get_arch().value}"
85+
)
86+
return jres[0]
87+
88+
def __get_jre_from_cache(self, jre: JRE) -> Optional[pathlib.Path]:
89+
cache_file = self.cache.get_file(jre.filename, jre.sha256)
90+
return cache_file.filepath if cache_file.is_valid() else None
91+
92+
def __download_jre(self, jre: JRE) -> Optional[pathlib.Path]:
93+
cache_file = self.cache.get_file(jre.filename, jre.sha256)
94+
cache_file.filepath.unlink(missing_ok=True)
95+
96+
with cache_file.open(mode="wb") as f:
97+
self.api.download_analysis_jre(jre.id, f)
98+
99+
return cache_file.filepath if cache_file.is_valid() else None
100+
101+
def __unpack_jre(self, jre: JRE, file_path: pathlib.Path) -> JREResolvedPath:
102+
unzip_dir = self.__prepare_unzip_dir(file_path)
103+
self.__extract_jre(file_path, unzip_dir)
104+
return JREResolvedPath(unzip_dir / jre.java_path)
105+
106+
def __prepare_unzip_dir(self, file_path: pathlib.Path) -> pathlib.Path:
107+
unzip_dir = self.cache.get_file_path(f"{file_path}_unzip")
108+
try:
109+
if unzip_dir.exists():
110+
shutil.rmtree(unzip_dir)
111+
unzip_dir.mkdir(parents=True)
112+
return unzip_dir
113+
except OSError as e:
114+
raise JreProvisioningException(f"Failed to prepare unzip directory: {unzip_dir}") from e
115+
116+
def __extract_jre(self, file_path: pathlib.Path, unzip_dir: pathlib.Path):
117+
if file_path.suffix == ".zip":
118+
with zipfile.ZipFile(file_path, "r") as zip_ref:
119+
zip_ref.extractall(unzip_dir)
120+
elif file_path.suffix in [".gz", ".tgz"]:
121+
with tarfile.open(file_path, "r:gz") as tar_ref:
122+
tar_ref.extractall(unzip_dir, filter="data")
123+
else:
124+
raise UnsupportedArchiveFormat(f"Unsupported archive format: {file_path.suffix}")
125+
126+
127+
class JREResolver:
128+
def __init__(self, configuration: Configuration, jre_provisioner: JREProvisioner):
129+
self.configuration = configuration
130+
self.jre_provisioner = jre_provisioner
131+
132+
def resolve_jre(self) -> JREResolvedPath:
133+
exe_suffix = ".exe" if self.configuration.sonar.scanner.os == "windows" else ""
134+
if self.configuration.sonar.scanner.java_exe_path:
135+
return JREResolvedPath(pathlib.Path(self.configuration.sonar.scanner.java_exe_path))
136+
if not self.configuration.sonar.scanner.skip_jre_provisioning:
137+
return self.__provision_jre()
138+
java_path = pathlib.Path(f"java{exe_suffix}")
139+
return JREResolvedPath(java_path)
140+
141+
def __provision_jre(self) -> JREResolvedPath:
142+
return self.jre_provisioner.provision()

src/pysonar_scanner/utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
2020
import hashlib
21+
import pathlib
22+
import platform
23+
import re
2124
import typing
25+
from enum import Enum
2226

2327

2428
def remove_trailing_slash(url: str) -> str:
@@ -30,3 +34,56 @@ def calculate_checksum(filehandle: typing.BinaryIO) -> str:
3034
for byte_block in iter(lambda: filehandle.read(4096), b""):
3135
sha256_hash.update(byte_block)
3236
return sha256_hash.hexdigest()
37+
38+
39+
class Os(Enum):
40+
WINDOWS = "windows"
41+
LINUX = "linux"
42+
MACOS = "mac"
43+
ALPINE = "alpine"
44+
OTHER = "other"
45+
46+
47+
def get_os() -> Os:
48+
def is_alpine() -> bool:
49+
try:
50+
os_release = pathlib.Path("/etc/os-release")
51+
if not os_release.exists():
52+
os_release = pathlib.Path("/usr/lib/os-release")
53+
if not os_release.exists():
54+
return False
55+
os_release_str = os_release.read_text()
56+
return "ID=alpine" in os_release_str or 'ID="alpine"' in os_release_str
57+
except OSError:
58+
return False
59+
60+
os_name = platform.system()
61+
if os_name == "Windows":
62+
return Os.WINDOWS
63+
elif os_name == "Darwin":
64+
return Os.MACOS
65+
elif os_name == "Linux":
66+
if is_alpine():
67+
return Os.ALPINE
68+
else:
69+
return Os.LINUX
70+
return Os.OTHER
71+
72+
73+
class Arch(Enum):
74+
X64 = "x64"
75+
AARCH64 = "aarch64"
76+
OTHER = "other"
77+
78+
79+
def get_arch() -> Arch:
80+
machine = platform.machine().lower()
81+
if machine in ["amd64", "x86_64"]:
82+
return Arch.X64
83+
elif machine == "arm64":
84+
return Arch.AARCH64
85+
return Arch.OTHER
86+
87+
88+
def filter_none_values(dictionary: dict) -> dict:
89+
return {k: v for k, v in dictionary.items() if v is not None}

0 commit comments

Comments
 (0)