cf-runtime 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cf_runtime/__init__.py +74 -0
- cf_runtime/bootstrap.py +376 -0
- cf_runtime/domain.py +25 -0
- cf_runtime/installed_seed.py +57 -0
- cf_runtime/lifecycle.py +457 -0
- cf_runtime/locking.py +64 -0
- cf_runtime/managed_tools.py +373 -0
- cf_runtime/manifest.py +464 -0
- cf_runtime/paths.py +257 -0
- cf_runtime/semantics/package.trig +36 -0
- cf_runtime/semantics.py +392 -0
- cf_runtime/target.py +137 -0
- cf_runtime/teardown.py +237 -0
- cf_runtime-0.1.8.dist-info/METADATA +35 -0
- cf_runtime-0.1.8.dist-info/RECORD +17 -0
- cf_runtime-0.1.8.dist-info/WHEEL +4 -0
- cf_runtime-0.1.8.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Pinned, verified acquisition of the Java and Fuseki runtime tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import platform
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from typing import Callable, Iterable
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import zipfile
|
|
22
|
+
|
|
23
|
+
from .locking import LifecycleLock
|
|
24
|
+
from .paths import CogniflowHome
|
|
25
|
+
|
|
26
|
+
MAX_BYTES = 512 * 1024 * 1024
|
|
27
|
+
MAX_FILES = 100_000
|
|
28
|
+
MAX_EXTRACTED = 2 * 1024 * 1024 * 1024
|
|
29
|
+
_REPARSE_POINT = 0x0400
|
|
30
|
+
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+\-]*$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ManagedToolsError(ValueError):
|
|
34
|
+
"""Expected provisioning failure with a stable machine-readable code."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, code: str, message: str) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.code = code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MANAGED_REQUEST_SCHEMA = "cf.runtime.managed-tools.request.v1"
|
|
42
|
+
MANAGED_RESULT_SCHEMA = "cf.runtime.managed-tools.result.v1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Artifact:
|
|
47
|
+
name: str
|
|
48
|
+
version: str
|
|
49
|
+
platform: str
|
|
50
|
+
architecture: str
|
|
51
|
+
archive_format: str
|
|
52
|
+
url: str
|
|
53
|
+
sha256: str
|
|
54
|
+
archive_root: str
|
|
55
|
+
executable: str
|
|
56
|
+
version_command: tuple[str, ...]
|
|
57
|
+
version_pattern: str
|
|
58
|
+
size: int
|
|
59
|
+
|
|
60
|
+
def validate(self) -> None:
|
|
61
|
+
if self.name not in {"java", "fuseki"} or not _VERSION.fullmatch(self.version):
|
|
62
|
+
raise ManagedToolsError("CATALOG_INVALID", "invalid tool identity or version")
|
|
63
|
+
if self.archive_format not in {"zip", "tar.gz"} or not self.archive_root or not self.executable:
|
|
64
|
+
raise ManagedToolsError("CATALOG_INVALID", "invalid archive layout")
|
|
65
|
+
parsed = urllib.parse.urlparse(self.url)
|
|
66
|
+
if parsed.scheme not in {"https", "file"} or not parsed.netloc and parsed.scheme == "https":
|
|
67
|
+
raise ManagedToolsError("CATALOG_INVALID", "artifact URL must be HTTPS or a fixture file URL")
|
|
68
|
+
if not re.fullmatch(r"[0-9a-f]{64}", self.sha256) or self.size <= 0 or self.size > MAX_BYTES:
|
|
69
|
+
raise ManagedToolsError("CATALOG_INVALID", "invalid artifact digest or size")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalized_platform() -> tuple[str, str]:
|
|
73
|
+
system = platform.system().lower()
|
|
74
|
+
machine = platform.machine().lower()
|
|
75
|
+
if system == "windows" and machine in {"amd64", "x86_64"}:
|
|
76
|
+
return "windows", "x86_64"
|
|
77
|
+
if system == "linux" and machine in {"amd64", "x86_64"}:
|
|
78
|
+
return "linux", "x86_64"
|
|
79
|
+
raise ManagedToolsError("UNSUPPORTED_PLATFORM", "only Windows/Linux x86_64 are supported")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _artifact(name: str, version: str, plat: str, arch: str, fmt: str, url: str,
|
|
83
|
+
digest: str, root: str, executable: str, command: tuple[str, ...],
|
|
84
|
+
pattern: str, size: int) -> Artifact:
|
|
85
|
+
return Artifact(name, version, plat, arch, fmt, url, digest, root, executable, command, pattern, size)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def catalog() -> tuple[Artifact, ...]:
|
|
89
|
+
java_version = "21.0.10+7"
|
|
90
|
+
base = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.10%2B7/"
|
|
91
|
+
java = {
|
|
92
|
+
("windows", "x86_64"): _artifact("java", java_version, "windows", "x86_64", "zip", base + "OpenJDK21U-jdk_x64_windows_hotspot_21.0.10_7.zip", "08cae782814f027f8b159d6b68823f0f87422eb475c1a0ea1abc7a4357aaf11f", "jdk-21.0.10+7", "bin/java.exe", ("-version",), r"21\.0\.10", 204_939_612),
|
|
93
|
+
("linux", "x86_64"): _artifact("java", java_version, "linux", "x86_64", "tar.gz", base + "OpenJDK21U-jdk_x64_linux_hotspot_21.0.10_7.tar.gz", "ea3b9bd464d6dd253e9a7accf59f7ccd2a36e4aa69640b7251e3370caef896a4", "jdk-21.0.10+7", "bin/java", ("-version",), r"21\.0\.10", 207_216_881),
|
|
94
|
+
}
|
|
95
|
+
fuseki_base = "https://archive.apache.org/dist/jena/binaries/apache-jena-fuseki-6.0.0.zip"
|
|
96
|
+
fuseki = _artifact("fuseki", "6.0.0", "any", "x86_64", "zip", fuseki_base, "08285d130def0928e4a414e6750a09a2924c74407f395b133e914575c722b7ef", "apache-jena-fuseki-6.0.0", "fuseki-server.jar", ("--version",), r"6\.0\.0", 51_253_805)
|
|
97
|
+
# The Apache checksum is resolved once from the official release archive.
|
|
98
|
+
# A missing digest intentionally makes normal provisioning fail closed.
|
|
99
|
+
return (*java.values(), fuseki)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def bind_release_lock(records: object) -> tuple[Artifact, ...]:
|
|
103
|
+
"""Bind release-profile identities to the executable runtime catalog."""
|
|
104
|
+
if not isinstance(records, list):
|
|
105
|
+
raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock must be a list")
|
|
106
|
+
expected = {
|
|
107
|
+
"fuseki": next(item for item in catalog() if item.name == "fuseki"),
|
|
108
|
+
"java-linux-x86_64": next(item for item in catalog() if item.name == "java" and item.platform == "linux"),
|
|
109
|
+
"java-windows-x86_64": next(item for item in catalog() if item.name == "java" and item.platform == "windows"),
|
|
110
|
+
}
|
|
111
|
+
if [item.get("name") if isinstance(item, dict) else None for item in records] != sorted(expected):
|
|
112
|
+
raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock is incomplete")
|
|
113
|
+
for record in records:
|
|
114
|
+
if not isinstance(record, dict) or set(record) != {"name", "version", "url", "sha256", "size"}:
|
|
115
|
+
raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock entry is invalid")
|
|
116
|
+
spec = expected[record["name"]]
|
|
117
|
+
actual = {key: record[key] for key in ("version", "url", "sha256", "size")}
|
|
118
|
+
locked = {"version": spec.version, "url": spec.url, "sha256": spec.sha256, "size": spec.size}
|
|
119
|
+
if actual != locked:
|
|
120
|
+
raise ManagedToolsError("RELEASE_LOCK_DIVERGED", f"runtime catalog diverges from release lock for {record['name']}")
|
|
121
|
+
return tuple(expected[name] for name in sorted(expected))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _safe_relative(value: str) -> PurePosixPath:
|
|
125
|
+
value = value.replace("\\", "/")
|
|
126
|
+
path = PurePosixPath(value)
|
|
127
|
+
if path.is_absolute() or value.startswith(("/", "//")) or re.match(r"^[A-Za-z]:", value) or ".." in path.parts:
|
|
128
|
+
raise ManagedToolsError("ARCHIVE_UNSAFE", "archive contains an escaping path")
|
|
129
|
+
if not path.parts or any(not part or part == "." for part in path.parts):
|
|
130
|
+
raise ManagedToolsError("ARCHIVE_UNSAFE", "archive contains an invalid path")
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _safe_link_target(member_name: str, linkname: str, expected_root: str) -> PurePosixPath:
|
|
135
|
+
target = linkname.replace("\\", "/")
|
|
136
|
+
if target.startswith(("/", "//")) or re.match(r"^[A-Za-z]:", target):
|
|
137
|
+
raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
|
|
138
|
+
parts = list(PurePosixPath(member_name).parent.parts)
|
|
139
|
+
for part in PurePosixPath(target).parts:
|
|
140
|
+
if part in {"", "."}:
|
|
141
|
+
continue
|
|
142
|
+
if part == "..":
|
|
143
|
+
if not parts or parts[-1] == expected_root:
|
|
144
|
+
raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
|
|
145
|
+
parts.pop()
|
|
146
|
+
else:
|
|
147
|
+
parts.append(part)
|
|
148
|
+
if not parts or parts[0] != expected_root:
|
|
149
|
+
raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
|
|
150
|
+
return PurePosixPath(*parts)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _exists_without_following(path: Path) -> bool:
|
|
154
|
+
return os.path.lexists(path)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_redirect(path: Path) -> bool:
|
|
158
|
+
if path.is_symlink():
|
|
159
|
+
return True
|
|
160
|
+
if os.name == "nt":
|
|
161
|
+
try:
|
|
162
|
+
return bool(os.lstat(path).st_file_attributes & _REPARSE_POINT)
|
|
163
|
+
except (AttributeError, FileNotFoundError, OSError):
|
|
164
|
+
return False
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_names(names: Iterable[str], expected_root: str) -> list[PurePosixPath]:
|
|
169
|
+
paths = [_safe_relative(name) for name in names]
|
|
170
|
+
if not paths or any(path.parts[0] != expected_root for path in paths):
|
|
171
|
+
raise ManagedToolsError("ARCHIVE_ROOT_INVALID", "unexpected archive root")
|
|
172
|
+
folded: set[str] = set()
|
|
173
|
+
for path in paths:
|
|
174
|
+
key = "/".join(path.parts).casefold()
|
|
175
|
+
if key in folded:
|
|
176
|
+
raise ManagedToolsError("ARCHIVE_DUPLICATE", "duplicate or case-colliding archive path")
|
|
177
|
+
folded.add(key)
|
|
178
|
+
return paths
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_zip(archive: Path, spec: Artifact) -> None:
|
|
182
|
+
with zipfile.ZipFile(archive) as zf:
|
|
183
|
+
infos = zf.infolist()
|
|
184
|
+
if len(infos) > MAX_FILES or sum(max(0, i.file_size) for i in infos) > MAX_EXTRACTED:
|
|
185
|
+
raise ManagedToolsError("ARCHIVE_LIMIT", "archive exceeds extraction limits")
|
|
186
|
+
_validate_names((i.filename for i in infos), spec.archive_root)
|
|
187
|
+
for info in infos:
|
|
188
|
+
mode = (info.external_attr >> 16) & 0o170000
|
|
189
|
+
if mode in {stat.S_IFLNK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFIFO, stat.S_IFSOCK}:
|
|
190
|
+
raise ManagedToolsError("ARCHIVE_LINK", "links and special files are not allowed")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validate_tar(archive: Path, spec: Artifact) -> None:
|
|
194
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
195
|
+
members = tf.getmembers()
|
|
196
|
+
if len(members) > MAX_FILES or sum(max(0, m.size) for m in members) > MAX_EXTRACTED:
|
|
197
|
+
raise ManagedToolsError("ARCHIVE_LIMIT", "archive exceeds extraction limits")
|
|
198
|
+
_validate_names((m.name for m in members), spec.archive_root)
|
|
199
|
+
for member in members:
|
|
200
|
+
if member.issym():
|
|
201
|
+
_safe_link_target(member.name, member.linkname, spec.archive_root)
|
|
202
|
+
elif member.islnk() or not (member.isfile() or member.isdir()):
|
|
203
|
+
raise ManagedToolsError("ARCHIVE_LINK", "hardlinks and special files are not allowed")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract(archive: Path, spec: Artifact, destination: Path) -> None:
|
|
207
|
+
if spec.archive_format == "zip":
|
|
208
|
+
_validate_zip(archive, spec)
|
|
209
|
+
with zipfile.ZipFile(archive) as zf:
|
|
210
|
+
zf.extractall(destination)
|
|
211
|
+
else:
|
|
212
|
+
_validate_tar(archive, spec)
|
|
213
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
214
|
+
tf.extractall(destination, filter="data")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _run(command: list[str], cwd: Path, timeout: float, env: dict[str, str]) -> str:
|
|
218
|
+
try:
|
|
219
|
+
result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True,
|
|
220
|
+
timeout=timeout, check=False, encoding="utf-8", errors="replace")
|
|
221
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
222
|
+
raise ManagedToolsError("TOOL_INVALID", "managed tool command failed") from error
|
|
223
|
+
output = (result.stdout + "\n" + result.stderr)[:16_384]
|
|
224
|
+
if result.returncode != 0:
|
|
225
|
+
raise ManagedToolsError("TOOL_INVALID", "managed tool returned a failure")
|
|
226
|
+
return output
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ManagedTools:
|
|
230
|
+
def __init__(self, home: CogniflowHome, *, artifacts: Iterable[Artifact] | None = None,
|
|
231
|
+
opener: Callable[..., object] = urllib.request.urlopen,
|
|
232
|
+
lock_timeout: float = 0.0) -> None:
|
|
233
|
+
self.home = home
|
|
234
|
+
self.artifacts = tuple(artifacts or catalog())
|
|
235
|
+
self.opener = opener
|
|
236
|
+
self.lock_timeout = lock_timeout
|
|
237
|
+
|
|
238
|
+
def _find(self, name: str, version: str) -> Artifact:
|
|
239
|
+
plat, arch = normalized_platform()
|
|
240
|
+
matches = [a for a in self.artifacts if a.name == name and a.version == version and
|
|
241
|
+
(a.platform, a.architecture) in {(plat, arch), ("any", arch)}]
|
|
242
|
+
if len(matches) != 1:
|
|
243
|
+
raise ManagedToolsError("ARTIFACT_UNAVAILABLE", f"no exact {name} artifact for {plat}/{arch}")
|
|
244
|
+
matches[0].validate()
|
|
245
|
+
return matches[0]
|
|
246
|
+
|
|
247
|
+
def _download(self, spec: Artifact) -> Path:
|
|
248
|
+
cache = self.home.tool_cache
|
|
249
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
suffix = ".zip" if spec.archive_format == "zip" else ".tar.gz"
|
|
251
|
+
target = cache / f"{spec.name}-{spec.version}-{spec.platform}-{spec.architecture}{suffix}"
|
|
252
|
+
if target.is_file() and target.stat().st_size == spec.size and _sha256(target) == spec.sha256:
|
|
253
|
+
return target
|
|
254
|
+
temporary = cache / f".{target.name}.{os.getpid()}.{time.time_ns()}.part"
|
|
255
|
+
try:
|
|
256
|
+
request = urllib.request.Request(spec.url, headers={"User-Agent": "cogniflow-cf-runtime/0.1"})
|
|
257
|
+
with self.opener(request, timeout=30) as response, temporary.open("wb") as stream:
|
|
258
|
+
digest = hashlib.sha256()
|
|
259
|
+
total = 0
|
|
260
|
+
while True:
|
|
261
|
+
chunk = response.read(1024 * 1024)
|
|
262
|
+
if not chunk:
|
|
263
|
+
break
|
|
264
|
+
total += len(chunk)
|
|
265
|
+
if total > spec.size or total > MAX_BYTES:
|
|
266
|
+
raise ManagedToolsError("DOWNLOAD_TOO_LARGE", "download exceeds catalog size")
|
|
267
|
+
digest.update(chunk)
|
|
268
|
+
stream.write(chunk)
|
|
269
|
+
stream.flush(); os.fsync(stream.fileno())
|
|
270
|
+
if total != spec.size or digest.hexdigest() != spec.sha256:
|
|
271
|
+
raise ManagedToolsError("CHECKSUM_MISMATCH", "download digest or size does not match catalog")
|
|
272
|
+
os.replace(temporary, target)
|
|
273
|
+
return target
|
|
274
|
+
finally:
|
|
275
|
+
temporary.unlink(missing_ok=True)
|
|
276
|
+
|
|
277
|
+
def provision(self, java_version: str, fuseki_version: str) -> dict[str, object]:
|
|
278
|
+
if not isinstance(java_version, str) or not isinstance(fuseki_version, str):
|
|
279
|
+
raise ManagedToolsError("INVALID_REQUEST", "tool versions must be strings")
|
|
280
|
+
java = self._find("java", java_version)
|
|
281
|
+
fuseki = self._find("fuseki", fuseki_version)
|
|
282
|
+
with LifecycleLock(self.home.tool_locks, self.lock_timeout):
|
|
283
|
+
self.home.root.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
java_root = self.home.java_install(java.version)
|
|
285
|
+
fuseki_root = self.home.fuseki_install(fuseki.version)
|
|
286
|
+
java_result = self._install(java, java_root, "java")
|
|
287
|
+
fuseki_result = self._install(fuseki, fuseki_root, "fuseki")
|
|
288
|
+
return {"schema": MANAGED_RESULT_SCHEMA,
|
|
289
|
+
"status": "unchanged" if java_result == fuseki_result == "unchanged" else "ready",
|
|
290
|
+
"java": self._result(java, java_root, java_root / java.executable, "java"),
|
|
291
|
+
"fuseki": self._result(fuseki, fuseki_root, fuseki_root / fuseki.executable, "fuseki")}
|
|
292
|
+
|
|
293
|
+
def _install(self, spec: Artifact, final: Path, kind: str) -> str:
|
|
294
|
+
if _exists_without_following(final) and _is_redirect(final):
|
|
295
|
+
raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is a redirect")
|
|
296
|
+
marker = final / ".cf-managed.json"
|
|
297
|
+
if _exists_without_following(final) and not final.is_dir():
|
|
298
|
+
raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is not a directory")
|
|
299
|
+
if _exists_without_following(final) and marker.is_file() and not _is_redirect(marker):
|
|
300
|
+
try:
|
|
301
|
+
data = json.loads(marker.read_text(encoding="utf-8"))
|
|
302
|
+
if data == {"name": spec.name, "version": spec.version, "sha256": spec.sha256}:
|
|
303
|
+
self._validate_install(final, spec, kind)
|
|
304
|
+
return "unchanged"
|
|
305
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
306
|
+
pass
|
|
307
|
+
if _exists_without_following(final):
|
|
308
|
+
raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is not owned by cf_runtime")
|
|
309
|
+
archive = self._download(spec)
|
|
310
|
+
staging = final.parent / f".{final.name}.{os.getpid()}.{time.time_ns()}.staging"
|
|
311
|
+
try:
|
|
312
|
+
final.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
staging.mkdir(parents=True)
|
|
314
|
+
_extract(archive, spec, staging)
|
|
315
|
+
source = staging / spec.archive_root
|
|
316
|
+
if not source.is_dir():
|
|
317
|
+
raise ManagedToolsError("ARCHIVE_ROOT_INVALID", "archive root is not a directory")
|
|
318
|
+
os.replace(source, staging / "payload")
|
|
319
|
+
payload = staging / "payload"
|
|
320
|
+
self._validate_install(payload, spec, kind)
|
|
321
|
+
(payload / ".cf-managed.json").write_text(json.dumps({"name": spec.name, "version": spec.version, "sha256": spec.sha256}, sort_keys=True) + "\n", encoding="utf-8")
|
|
322
|
+
os.replace(payload, final)
|
|
323
|
+
return "ready"
|
|
324
|
+
finally:
|
|
325
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
326
|
+
|
|
327
|
+
def _validate_install(self, root: Path, spec: Artifact, kind: str) -> None:
|
|
328
|
+
candidate = root / spec.executable
|
|
329
|
+
current = root
|
|
330
|
+
for part in candidate.relative_to(root).parts:
|
|
331
|
+
current = current / part
|
|
332
|
+
if current.is_symlink():
|
|
333
|
+
raise ManagedToolsError("TOOL_INVALID", f"managed {kind} executable is a symlink")
|
|
334
|
+
path = candidate.resolve(strict=False)
|
|
335
|
+
if not path.is_file() or root.resolve() not in path.parents:
|
|
336
|
+
raise ManagedToolsError("TOOL_INVALID", f"managed {kind} executable is invalid")
|
|
337
|
+
if kind == "java":
|
|
338
|
+
env = {k: v for k, v in os.environ.items() if k not in {"JAVA_HOME", "JDK_HOME"}}
|
|
339
|
+
output = _run([str(path), *spec.version_command], root, 10, env)
|
|
340
|
+
else:
|
|
341
|
+
java = self.home.java_install("21.0.10+7") / "bin" / ("java.exe" if os.name == "nt" else "java")
|
|
342
|
+
if not java.is_file() or java.resolve().is_symlink():
|
|
343
|
+
raise ManagedToolsError("TOOL_INVALID", "managed Java is required for Fuseki verification")
|
|
344
|
+
output = _run([str(java), "-jar", str(path), *spec.version_command], root, 10, {})
|
|
345
|
+
if not re.search(spec.version_pattern, output):
|
|
346
|
+
raise ManagedToolsError("VERSION_MISMATCH", f"{kind} reported an unexpected version")
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def _result(spec: Artifact, root: Path, executable: Path, kind: str) -> dict[str, object]:
|
|
350
|
+
lock_record = {
|
|
351
|
+
"name": spec.name,
|
|
352
|
+
"version": spec.version,
|
|
353
|
+
"platform": spec.platform,
|
|
354
|
+
"architecture": spec.architecture,
|
|
355
|
+
"archive_format": spec.archive_format,
|
|
356
|
+
"url": spec.url,
|
|
357
|
+
"sha256": spec.sha256,
|
|
358
|
+
"size": spec.size,
|
|
359
|
+
}
|
|
360
|
+
lock_digest = "sha256:" + hashlib.sha256(
|
|
361
|
+
(json.dumps(lock_record, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
362
|
+
).hexdigest()
|
|
363
|
+
result = {"version": spec.version, "home": str(root), "artifact_sha256": spec.sha256, "lock_record": lock_record, "lock_digest": lock_digest}
|
|
364
|
+
result["executable" if kind == "java" else "server_artifact"] = str(executable)
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _sha256(path: Path) -> str:
|
|
369
|
+
digest = hashlib.sha256()
|
|
370
|
+
with path.open("rb") as stream:
|
|
371
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
372
|
+
digest.update(chunk)
|
|
373
|
+
return digest.hexdigest()
|