lean-runtime 0.6.0__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.
- lean_runtime/__init__.py +67 -0
- lean_runtime/__main__.py +4 -0
- lean_runtime/backends.py +373 -0
- lean_runtime/cli.py +254 -0
- lean_runtime/diagnostics.py +52 -0
- lean_runtime/environments.py +1170 -0
- lean_runtime/errors.py +63 -0
- lean_runtime/events.py +35 -0
- lean_runtime/health.py +86 -0
- lean_runtime/lake.py +43 -0
- lean_runtime/lockfiles.py +192 -0
- lean_runtime/locking.py +82 -0
- lean_runtime/models.py +78 -0
- lean_runtime/policies.py +36 -0
- lean_runtime/py.typed +1 -0
- lean_runtime/references.py +246 -0
- lean_runtime/resolver.py +282 -0
- lean_runtime/runtime.py +430 -0
- lean_runtime/serialization.py +51 -0
- lean_runtime/specs.py +210 -0
- lean_runtime/store.py +462 -0
- lean_runtime/toolchains.py +164 -0
- lean_runtime-0.6.0.dist-info/METADATA +332 -0
- lean_runtime-0.6.0.dist-info/RECORD +28 -0
- lean_runtime-0.6.0.dist-info/WHEEL +5 -0
- lean_runtime-0.6.0.dist-info/entry_points.txt +2 -0
- lean_runtime-0.6.0.dist-info/licenses/LICENSE +202 -0
- lean_runtime-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
"""Published environments and reproducible executions within them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
16
|
+
from contextlib import suppress
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from typing import Any, Generic, TextIO, TypeVar, cast
|
|
21
|
+
|
|
22
|
+
from .backends import Backend, BackendResult, InteractiveProcess, InteractiveTextReader
|
|
23
|
+
from .diagnostics import error_diagnostic, parse_diagnostics
|
|
24
|
+
from .errors import EnvironmentError, MaterializationError, PolicyError
|
|
25
|
+
from .events import EventEmitter
|
|
26
|
+
from .lake import ROOT_MODULE
|
|
27
|
+
from .lockfiles import EnvironmentLock
|
|
28
|
+
from .locking import FileLock
|
|
29
|
+
from .models import (
|
|
30
|
+
ExecutionProvenance,
|
|
31
|
+
ExecutionResult,
|
|
32
|
+
PackageProvenance,
|
|
33
|
+
)
|
|
34
|
+
from .policies import ExecutionPolicy
|
|
35
|
+
from .serialization import sha256_id, write_json_atomic
|
|
36
|
+
from .store import EnvironmentStore, clone_tree, environment_identity, platform_record
|
|
37
|
+
from .toolchains import ToolchainManager
|
|
38
|
+
|
|
39
|
+
ENVIRONMENT_SCHEMA = "lean-runtime-published-environment/1"
|
|
40
|
+
EXECUTION_SCHEMA = "lean-runtime-execution/1"
|
|
41
|
+
CAPTURE_SCHEMA = "lean-runtime-execution-capture/1"
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
_IMPORT = re.compile(r"^\s*import\s+(.+?)\s*$", re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now() -> str:
|
|
47
|
+
return datetime.now(timezone.utc).isoformat()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _lean_path(value: str) -> str:
|
|
51
|
+
normalized = PurePosixPath(value.replace("\\", "/"))
|
|
52
|
+
if (
|
|
53
|
+
not value
|
|
54
|
+
or normalized.is_absolute()
|
|
55
|
+
or ".." in normalized.parts
|
|
56
|
+
or normalized.suffix != ".lean"
|
|
57
|
+
):
|
|
58
|
+
raise EnvironmentError(f"unsafe Lean source path: {value!r}")
|
|
59
|
+
return normalized.as_posix()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _source_files(files: Mapping[str, str]) -> dict[str, str]:
|
|
63
|
+
if not files:
|
|
64
|
+
raise EnvironmentError("a check requires at least one Lean source file")
|
|
65
|
+
result: dict[str, str] = {}
|
|
66
|
+
for name, source in files.items():
|
|
67
|
+
if not isinstance(name, str) or not isinstance(source, str):
|
|
68
|
+
raise EnvironmentError("Lean source files must map paths to strings")
|
|
69
|
+
normalized = _lean_path(name)
|
|
70
|
+
if normalized in result:
|
|
71
|
+
raise EnvironmentError(f"duplicate normalized Lean source path: {normalized}")
|
|
72
|
+
result[normalized] = source
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _execution_command(command: Sequence[str]) -> tuple[str, ...]:
|
|
77
|
+
if not command:
|
|
78
|
+
raise EnvironmentError("an execution command must not be empty")
|
|
79
|
+
normalized = tuple(command)
|
|
80
|
+
if any(not isinstance(part, str) or not part or "\0" in part for part in normalized):
|
|
81
|
+
raise EnvironmentError("execution command elements must be non-empty strings without NULs")
|
|
82
|
+
return normalized
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _module_name(path: str) -> str:
|
|
86
|
+
return path.removesuffix(".lean").replace("/", ".")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _support_order(files: Mapping[str, str], entrypoint: str) -> tuple[str, ...]:
|
|
90
|
+
"""Topologically order submitted modules needed before the entrypoint."""
|
|
91
|
+
paths_by_module = {_module_name(path): path for path in files}
|
|
92
|
+
dependencies: dict[str, set[str]] = {}
|
|
93
|
+
for path, source in files.items():
|
|
94
|
+
imported: set[str] = set()
|
|
95
|
+
for match in _IMPORT.finditer(source):
|
|
96
|
+
for module in match.group(1).split():
|
|
97
|
+
if module in paths_by_module:
|
|
98
|
+
imported.add(paths_by_module[module])
|
|
99
|
+
dependencies[path] = imported
|
|
100
|
+
|
|
101
|
+
ordered: list[str] = []
|
|
102
|
+
visiting: set[str] = set()
|
|
103
|
+
visited: set[str] = set()
|
|
104
|
+
|
|
105
|
+
def visit(path: str) -> None:
|
|
106
|
+
if path in visited:
|
|
107
|
+
return
|
|
108
|
+
if path in visiting:
|
|
109
|
+
raise EnvironmentError(f"submitted Lean sources contain an import cycle at {path}")
|
|
110
|
+
visiting.add(path)
|
|
111
|
+
for dependency in sorted(dependencies[path]):
|
|
112
|
+
visit(dependency)
|
|
113
|
+
visiting.remove(path)
|
|
114
|
+
visited.add(path)
|
|
115
|
+
ordered.append(path)
|
|
116
|
+
|
|
117
|
+
visit(entrypoint)
|
|
118
|
+
return tuple(path for path in ordered if path != entrypoint)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _package_import_targets(files: Mapping[str, str], lock: EnvironmentLock) -> tuple[str, ...]:
|
|
122
|
+
"""Find imported package roots whose artifacts Lake may need on demand."""
|
|
123
|
+
package_names = {package.name.lower() for package in lock.packages}
|
|
124
|
+
package_names.update(
|
|
125
|
+
package.root_module.split(".", 1)[0].lower()
|
|
126
|
+
for package in lock.packages
|
|
127
|
+
if package.root_module
|
|
128
|
+
)
|
|
129
|
+
local_modules = {_module_name(path) for path in files}
|
|
130
|
+
targets: set[str] = set()
|
|
131
|
+
for source in files.values():
|
|
132
|
+
for match in _IMPORT.finditer(source):
|
|
133
|
+
for module in match.group(1).split():
|
|
134
|
+
root = module.split(".", 1)[0]
|
|
135
|
+
if module not in local_modules and root.lower() in package_names:
|
|
136
|
+
targets.add(root)
|
|
137
|
+
return tuple(sorted(targets))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class EnvironmentInfo:
|
|
142
|
+
environment_id: str
|
|
143
|
+
lock_id: str
|
|
144
|
+
toolchain: str
|
|
145
|
+
packages: int
|
|
146
|
+
platform: dict[str, str]
|
|
147
|
+
build_profile: str
|
|
148
|
+
status: str
|
|
149
|
+
created_at: str
|
|
150
|
+
names: tuple[str, ...] = ()
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> dict[str, Any]:
|
|
153
|
+
return asdict(self)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class ExecutionCapture:
|
|
158
|
+
environment_id: str
|
|
159
|
+
lock: EnvironmentLock
|
|
160
|
+
operation: str
|
|
161
|
+
files: dict[str, str]
|
|
162
|
+
entrypoint: str
|
|
163
|
+
policy: ExecutionPolicy
|
|
164
|
+
expected_ok: bool | None = None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def capture_id(self) -> str:
|
|
168
|
+
return sha256_id("capture", self.to_dict(include_id=False))
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def lock_id(self) -> str:
|
|
172
|
+
return self.lock.lock_id
|
|
173
|
+
|
|
174
|
+
def to_dict(self, *, include_id: bool = True) -> dict[str, Any]:
|
|
175
|
+
value = {
|
|
176
|
+
"schema": CAPTURE_SCHEMA,
|
|
177
|
+
"environment_id": self.environment_id,
|
|
178
|
+
"lock": self.lock.to_dict(),
|
|
179
|
+
"operation": self.operation,
|
|
180
|
+
"files": dict(sorted(self.files.items())),
|
|
181
|
+
"entrypoint": self.entrypoint,
|
|
182
|
+
"policy": self.policy.to_dict(),
|
|
183
|
+
"expected_ok": self.expected_ok,
|
|
184
|
+
}
|
|
185
|
+
return {"capture_id": self.capture_id, **value} if include_id else value
|
|
186
|
+
|
|
187
|
+
def write(self, path: str | os.PathLike[str]) -> None:
|
|
188
|
+
write_json_atomic(Path(path), self.to_dict())
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, value: dict[str, Any]) -> ExecutionCapture:
|
|
192
|
+
if value.get("schema") != CAPTURE_SCHEMA:
|
|
193
|
+
raise EnvironmentError(f"unsupported capture schema: {value.get('schema')!r}")
|
|
194
|
+
files = value.get("files")
|
|
195
|
+
if not isinstance(files, dict) or not files:
|
|
196
|
+
raise EnvironmentError("execution capture files must be a non-empty object")
|
|
197
|
+
normalized_files: dict[str, str] = {}
|
|
198
|
+
for name, source in files.items():
|
|
199
|
+
if not isinstance(name, str) or not isinstance(source, str):
|
|
200
|
+
raise EnvironmentError("execution capture contains an unsafe file entry")
|
|
201
|
+
normalized = _lean_path(name)
|
|
202
|
+
if normalized in normalized_files:
|
|
203
|
+
raise EnvironmentError(
|
|
204
|
+
f"execution capture contains duplicate source path: {normalized}"
|
|
205
|
+
)
|
|
206
|
+
normalized_files[normalized] = source
|
|
207
|
+
entrypoint_value = value.get("entrypoint")
|
|
208
|
+
if entrypoint_value is None and len(normalized_files) == 1:
|
|
209
|
+
entrypoint_value = next(iter(normalized_files))
|
|
210
|
+
if not isinstance(entrypoint_value, str):
|
|
211
|
+
raise EnvironmentError("execution capture entrypoint must be a Lean source path")
|
|
212
|
+
entrypoint = _lean_path(entrypoint_value)
|
|
213
|
+
if entrypoint not in normalized_files:
|
|
214
|
+
raise EnvironmentError("execution capture entrypoint is not present in files")
|
|
215
|
+
policy_value = value.get("policy")
|
|
216
|
+
if not isinstance(policy_value, dict):
|
|
217
|
+
raise EnvironmentError("execution capture policy must be an object")
|
|
218
|
+
expected_ok = value.get("expected_ok")
|
|
219
|
+
if expected_ok is not None and not isinstance(expected_ok, bool):
|
|
220
|
+
raise EnvironmentError("execution capture expected_ok must be Boolean or null")
|
|
221
|
+
capture = cls(
|
|
222
|
+
environment_id=str(value["environment_id"]),
|
|
223
|
+
lock=EnvironmentLock.from_dict(dict(value["lock"])),
|
|
224
|
+
operation=str(value["operation"]),
|
|
225
|
+
files=normalized_files,
|
|
226
|
+
entrypoint=entrypoint,
|
|
227
|
+
policy=ExecutionPolicy(**policy_value),
|
|
228
|
+
expected_ok=expected_ok,
|
|
229
|
+
)
|
|
230
|
+
recorded = value.get("capture_id")
|
|
231
|
+
if recorded is not None and recorded != capture.capture_id:
|
|
232
|
+
raise EnvironmentError("execution capture identity mismatch")
|
|
233
|
+
return capture
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def load(cls, path: str | os.PathLike[str]) -> ExecutionCapture:
|
|
237
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
238
|
+
if not isinstance(value, dict):
|
|
239
|
+
raise EnvironmentError("execution capture must contain an object")
|
|
240
|
+
return cls.from_dict(value)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ExecutionJob(Generic[T]):
|
|
244
|
+
"""A cancellable background execution."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, function: Any) -> None:
|
|
247
|
+
self._cancel = threading.Event()
|
|
248
|
+
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lean-runtime")
|
|
249
|
+
self._future: Future[T] = self._executor.submit(function, self._cancel)
|
|
250
|
+
|
|
251
|
+
def cancel(self) -> bool:
|
|
252
|
+
if self._future.done():
|
|
253
|
+
return False
|
|
254
|
+
self._cancel.set()
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
def done(self) -> bool:
|
|
258
|
+
return self._future.done()
|
|
259
|
+
|
|
260
|
+
def result(self, timeout: float | None = None) -> T:
|
|
261
|
+
try:
|
|
262
|
+
return self._future.result(timeout=timeout)
|
|
263
|
+
finally:
|
|
264
|
+
if self._future.done():
|
|
265
|
+
self._executor.shutdown(wait=False)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class InteractiveSession:
|
|
269
|
+
"""One live process inside a disposable environment instance."""
|
|
270
|
+
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
process: InteractiveProcess,
|
|
275
|
+
execution_id: str,
|
|
276
|
+
finalize: Callable[[BackendResult], ExecutionResult],
|
|
277
|
+
cleanup: Callable[[], None],
|
|
278
|
+
) -> None:
|
|
279
|
+
self._process = process
|
|
280
|
+
self._execution_id = execution_id
|
|
281
|
+
self._finalize = finalize
|
|
282
|
+
self._cleanup = cleanup
|
|
283
|
+
self._result: ExecutionResult | None = None
|
|
284
|
+
self._closing = threading.Lock()
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def stdin(self) -> TextIO:
|
|
288
|
+
return self._process.stdin
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def stdout(self) -> InteractiveTextReader:
|
|
292
|
+
return self._process.stdout
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def stderr(self) -> InteractiveTextReader:
|
|
296
|
+
return self._process.stderr
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def execution_id(self) -> str:
|
|
300
|
+
return self._execution_id
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def running(self) -> bool:
|
|
304
|
+
return self._result is None and self._process.poll() is None
|
|
305
|
+
|
|
306
|
+
def poll(self) -> int | None:
|
|
307
|
+
"""Return the process exit code, or ``None`` while it is running."""
|
|
308
|
+
return self._process.poll()
|
|
309
|
+
|
|
310
|
+
def close(self) -> ExecutionResult:
|
|
311
|
+
"""Send EOF, stop if necessary, persist provenance, and remove the instance."""
|
|
312
|
+
with self._closing:
|
|
313
|
+
if self._result is not None:
|
|
314
|
+
return self._result
|
|
315
|
+
try:
|
|
316
|
+
self._result = self._finalize(self._process.finish())
|
|
317
|
+
return self._result
|
|
318
|
+
finally:
|
|
319
|
+
self._cleanup()
|
|
320
|
+
|
|
321
|
+
def __enter__(self) -> InteractiveSession:
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def __exit__(self, _exc_type: object, _exc_value: object, _traceback: object) -> None:
|
|
325
|
+
self.close()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class Environment:
|
|
329
|
+
"""A lightweight handle to one published, content-addressed environment."""
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
manager: EnvironmentManager,
|
|
334
|
+
environment_id: str,
|
|
335
|
+
lock: EnvironmentLock,
|
|
336
|
+
root: Path,
|
|
337
|
+
record: dict[str, Any],
|
|
338
|
+
) -> None:
|
|
339
|
+
self.manager = manager
|
|
340
|
+
self.id = environment_id
|
|
341
|
+
self.lock = lock
|
|
342
|
+
self.root = root
|
|
343
|
+
self.workspace = root / "workspace"
|
|
344
|
+
self._record = record
|
|
345
|
+
|
|
346
|
+
def inspect(self) -> EnvironmentInfo:
|
|
347
|
+
names = tuple(
|
|
348
|
+
sorted(name for name, value in self.manager.store.aliases().items() if value == self.id)
|
|
349
|
+
)
|
|
350
|
+
return EnvironmentInfo(
|
|
351
|
+
environment_id=self.id,
|
|
352
|
+
lock_id=self.lock.lock_id,
|
|
353
|
+
toolchain=self.lock.toolchain,
|
|
354
|
+
packages=len(self.lock.packages),
|
|
355
|
+
platform=dict(self._record["platform"]),
|
|
356
|
+
build_profile=str(self._record["build_profile"]),
|
|
357
|
+
status=str(self._record["status"]),
|
|
358
|
+
created_at=str(self._record["created_at"]),
|
|
359
|
+
names=names,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def check(
|
|
363
|
+
self,
|
|
364
|
+
source: str,
|
|
365
|
+
*,
|
|
366
|
+
filename: str = "Main.lean",
|
|
367
|
+
policy: ExecutionPolicy | None = None,
|
|
368
|
+
cancel: threading.Event | None = None,
|
|
369
|
+
) -> ExecutionResult:
|
|
370
|
+
safe_filename = Path(filename).name
|
|
371
|
+
if not safe_filename.endswith(".lean"):
|
|
372
|
+
safe_filename += ".lean"
|
|
373
|
+
return self.check_files(
|
|
374
|
+
{safe_filename: source},
|
|
375
|
+
entrypoint=safe_filename,
|
|
376
|
+
policy=policy,
|
|
377
|
+
cancel=cancel,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def check_files(
|
|
381
|
+
self,
|
|
382
|
+
files: Mapping[str, str],
|
|
383
|
+
*,
|
|
384
|
+
entrypoint: str = "Main.lean",
|
|
385
|
+
policy: ExecutionPolicy | None = None,
|
|
386
|
+
cancel: threading.Event | None = None,
|
|
387
|
+
) -> ExecutionResult:
|
|
388
|
+
"""Check a safe relative tree of Lean files through one entrypoint."""
|
|
389
|
+
normalized = _source_files(files)
|
|
390
|
+
selected_entrypoint = _lean_path(entrypoint)
|
|
391
|
+
if selected_entrypoint not in normalized:
|
|
392
|
+
raise EnvironmentError(f"entrypoint is not present in files: {selected_entrypoint}")
|
|
393
|
+
return self._execute_in_instance(
|
|
394
|
+
operation="check",
|
|
395
|
+
files=normalized,
|
|
396
|
+
entrypoint=selected_entrypoint,
|
|
397
|
+
targets=(),
|
|
398
|
+
requested_command=(),
|
|
399
|
+
policy=policy or ExecutionPolicy(),
|
|
400
|
+
cancel=cancel,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def start_check(
|
|
404
|
+
self,
|
|
405
|
+
source: str,
|
|
406
|
+
*,
|
|
407
|
+
filename: str = "Main.lean",
|
|
408
|
+
policy: ExecutionPolicy | None = None,
|
|
409
|
+
) -> ExecutionJob[ExecutionResult]:
|
|
410
|
+
return ExecutionJob(
|
|
411
|
+
lambda cancel: self.check(source, filename=filename, policy=policy, cancel=cancel)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def check_async(
|
|
415
|
+
self,
|
|
416
|
+
source: str,
|
|
417
|
+
*,
|
|
418
|
+
filename: str = "Main.lean",
|
|
419
|
+
policy: ExecutionPolicy | None = None,
|
|
420
|
+
) -> ExecutionResult:
|
|
421
|
+
"""Check source without blocking an asyncio event loop."""
|
|
422
|
+
job = self.start_check(source, filename=filename, policy=policy)
|
|
423
|
+
return await self._await_job(job)
|
|
424
|
+
|
|
425
|
+
async def check_files_async(
|
|
426
|
+
self,
|
|
427
|
+
files: Mapping[str, str],
|
|
428
|
+
*,
|
|
429
|
+
entrypoint: str = "Main.lean",
|
|
430
|
+
policy: ExecutionPolicy | None = None,
|
|
431
|
+
) -> ExecutionResult:
|
|
432
|
+
"""Check a multi-file request and propagate coroutine cancellation."""
|
|
433
|
+
job: ExecutionJob[ExecutionResult] = ExecutionJob(
|
|
434
|
+
lambda cancel: self.check_files(
|
|
435
|
+
files, entrypoint=entrypoint, policy=policy, cancel=cancel
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
return await self._await_job(job)
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
async def _await_job(job: ExecutionJob[ExecutionResult]) -> ExecutionResult:
|
|
442
|
+
try:
|
|
443
|
+
return await asyncio.to_thread(job.result)
|
|
444
|
+
except asyncio.CancelledError:
|
|
445
|
+
job.cancel()
|
|
446
|
+
while not job.done():
|
|
447
|
+
await asyncio.sleep(0.01)
|
|
448
|
+
job.result()
|
|
449
|
+
raise
|
|
450
|
+
|
|
451
|
+
def check_many(
|
|
452
|
+
self,
|
|
453
|
+
sources: Sequence[str],
|
|
454
|
+
*,
|
|
455
|
+
concurrency: int = 4,
|
|
456
|
+
policy: ExecutionPolicy | None = None,
|
|
457
|
+
) -> tuple[ExecutionResult, ...]:
|
|
458
|
+
if concurrency < 1:
|
|
459
|
+
raise ValueError("concurrency must be positive")
|
|
460
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
461
|
+
futures = [executor.submit(self.check, source, policy=policy) for source in sources]
|
|
462
|
+
return tuple(future.result() for future in futures)
|
|
463
|
+
|
|
464
|
+
async def check_many_async(
|
|
465
|
+
self,
|
|
466
|
+
sources: Sequence[str],
|
|
467
|
+
*,
|
|
468
|
+
concurrency: int = 4,
|
|
469
|
+
policy: ExecutionPolicy | None = None,
|
|
470
|
+
) -> tuple[ExecutionResult, ...]:
|
|
471
|
+
if concurrency < 1:
|
|
472
|
+
raise ValueError("concurrency must be positive")
|
|
473
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
474
|
+
|
|
475
|
+
async def check_one(source: str) -> ExecutionResult:
|
|
476
|
+
async with semaphore:
|
|
477
|
+
return await self.check_async(source, policy=policy)
|
|
478
|
+
|
|
479
|
+
return tuple(await asyncio.gather(*(check_one(source) for source in sources)))
|
|
480
|
+
|
|
481
|
+
def build(
|
|
482
|
+
self,
|
|
483
|
+
targets: Sequence[str] = (),
|
|
484
|
+
*,
|
|
485
|
+
policy: ExecutionPolicy | None = None,
|
|
486
|
+
cancel: threading.Event | None = None,
|
|
487
|
+
) -> ExecutionResult:
|
|
488
|
+
return self._execute_in_instance(
|
|
489
|
+
operation="build",
|
|
490
|
+
files={},
|
|
491
|
+
entrypoint=None,
|
|
492
|
+
targets=tuple(targets),
|
|
493
|
+
requested_command=(),
|
|
494
|
+
policy=policy or ExecutionPolicy(timeout_seconds=900, max_output_bytes=10_000_000),
|
|
495
|
+
cancel=cancel,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def execute(
|
|
499
|
+
self,
|
|
500
|
+
command: Sequence[str],
|
|
501
|
+
*,
|
|
502
|
+
policy: ExecutionPolicy | None = None,
|
|
503
|
+
cancel: threading.Event | None = None,
|
|
504
|
+
) -> ExecutionResult:
|
|
505
|
+
"""Run a command, such as ``lake exe TARGET``, in a disposable instance."""
|
|
506
|
+
return self._execute_in_instance(
|
|
507
|
+
operation="execute",
|
|
508
|
+
files={},
|
|
509
|
+
entrypoint=None,
|
|
510
|
+
targets=(),
|
|
511
|
+
requested_command=_execution_command(command),
|
|
512
|
+
policy=policy or ExecutionPolicy(),
|
|
513
|
+
cancel=cancel,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def spawn_interactive(
|
|
517
|
+
self,
|
|
518
|
+
command: Sequence[str],
|
|
519
|
+
*,
|
|
520
|
+
policy: ExecutionPolicy | None = None,
|
|
521
|
+
) -> InteractiveSession:
|
|
522
|
+
"""Start a long-running command with live text pipes in a disposable instance."""
|
|
523
|
+
requested_command = _execution_command(command)
|
|
524
|
+
selected_policy = policy or ExecutionPolicy()
|
|
525
|
+
source_digest = sha256_id("files", {})
|
|
526
|
+
request_digest = sha256_id(
|
|
527
|
+
"request",
|
|
528
|
+
{
|
|
529
|
+
"schema": EXECUTION_SCHEMA,
|
|
530
|
+
"environment_id": self.id,
|
|
531
|
+
"operation": "interactive",
|
|
532
|
+
"source_digest": source_digest,
|
|
533
|
+
"command": list(requested_command),
|
|
534
|
+
"policy": selected_policy.to_dict(),
|
|
535
|
+
"backend": self.manager.backend.name,
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
started_at = _now()
|
|
539
|
+
execution_id = sha256_id(
|
|
540
|
+
"execution",
|
|
541
|
+
{
|
|
542
|
+
"request_digest": request_digest,
|
|
543
|
+
"started_at": started_at,
|
|
544
|
+
"nonce": uuid.uuid4().hex,
|
|
545
|
+
},
|
|
546
|
+
)
|
|
547
|
+
job_parent = self.manager.store.jobs / execution_id
|
|
548
|
+
job_parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
instance = job_parent / f"instance-{uuid.uuid4().hex}"
|
|
550
|
+
|
|
551
|
+
def cleanup() -> None:
|
|
552
|
+
if instance.exists():
|
|
553
|
+
shutil.rmtree(instance)
|
|
554
|
+
with suppress(OSError):
|
|
555
|
+
job_parent.rmdir()
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
with self.manager.store.execution_lease(self.id):
|
|
559
|
+
clone_tree(self.workspace, instance)
|
|
560
|
+
resolved_command = self.manager.toolchains.command(
|
|
561
|
+
self.lock.toolchain, requested_command[0], *requested_command[1:]
|
|
562
|
+
)
|
|
563
|
+
spawn = getattr(self.manager.backend, "spawn_interactive", None)
|
|
564
|
+
if not callable(spawn):
|
|
565
|
+
raise PolicyError(
|
|
566
|
+
f"backend {self.manager.backend.name!r} does not support interactive execution"
|
|
567
|
+
)
|
|
568
|
+
process = cast(
|
|
569
|
+
InteractiveProcess,
|
|
570
|
+
spawn(
|
|
571
|
+
resolved_command,
|
|
572
|
+
cwd=instance,
|
|
573
|
+
environment=self.manager.toolchains.environment,
|
|
574
|
+
policy=selected_policy,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
except BaseException:
|
|
578
|
+
cleanup()
|
|
579
|
+
raise
|
|
580
|
+
|
|
581
|
+
def finalize(raw: BackendResult) -> ExecutionResult:
|
|
582
|
+
result = self._result(
|
|
583
|
+
raw,
|
|
584
|
+
command=resolved_command,
|
|
585
|
+
cwd=instance,
|
|
586
|
+
execution_id=execution_id,
|
|
587
|
+
request_digest=request_digest,
|
|
588
|
+
source_digest=source_digest,
|
|
589
|
+
started_at=started_at,
|
|
590
|
+
policy=selected_policy,
|
|
591
|
+
)
|
|
592
|
+
self._record_execution(
|
|
593
|
+
result,
|
|
594
|
+
"interactive",
|
|
595
|
+
(),
|
|
596
|
+
None,
|
|
597
|
+
(),
|
|
598
|
+
requested_command,
|
|
599
|
+
)
|
|
600
|
+
return result
|
|
601
|
+
|
|
602
|
+
return InteractiveSession(
|
|
603
|
+
process=process,
|
|
604
|
+
execution_id=execution_id,
|
|
605
|
+
finalize=finalize,
|
|
606
|
+
cleanup=cleanup,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def capture(
|
|
610
|
+
self,
|
|
611
|
+
source: str,
|
|
612
|
+
*,
|
|
613
|
+
filename: str = "Main.lean",
|
|
614
|
+
policy: ExecutionPolicy | None = None,
|
|
615
|
+
expected_ok: bool | None = None,
|
|
616
|
+
) -> ExecutionCapture:
|
|
617
|
+
safe_filename = Path(filename).name
|
|
618
|
+
if not safe_filename.endswith(".lean"):
|
|
619
|
+
safe_filename += ".lean"
|
|
620
|
+
return self.capture_files(
|
|
621
|
+
{safe_filename: source},
|
|
622
|
+
entrypoint=safe_filename,
|
|
623
|
+
policy=policy,
|
|
624
|
+
expected_ok=expected_ok,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def capture_files(
|
|
628
|
+
self,
|
|
629
|
+
files: Mapping[str, str],
|
|
630
|
+
*,
|
|
631
|
+
entrypoint: str = "Main.lean",
|
|
632
|
+
policy: ExecutionPolicy | None = None,
|
|
633
|
+
expected_ok: bool | None = None,
|
|
634
|
+
) -> ExecutionCapture:
|
|
635
|
+
normalized = _source_files(files)
|
|
636
|
+
selected_entrypoint = _lean_path(entrypoint)
|
|
637
|
+
if selected_entrypoint not in normalized:
|
|
638
|
+
raise EnvironmentError(f"entrypoint is not present in files: {selected_entrypoint}")
|
|
639
|
+
return ExecutionCapture(
|
|
640
|
+
environment_id=self.id,
|
|
641
|
+
lock=self.lock,
|
|
642
|
+
operation="check",
|
|
643
|
+
files=normalized,
|
|
644
|
+
entrypoint=selected_entrypoint,
|
|
645
|
+
policy=policy or ExecutionPolicy(),
|
|
646
|
+
expected_ok=expected_ok,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
def _execute_in_instance(
|
|
650
|
+
self,
|
|
651
|
+
*,
|
|
652
|
+
operation: str,
|
|
653
|
+
files: dict[str, str],
|
|
654
|
+
entrypoint: str | None,
|
|
655
|
+
targets: tuple[str, ...],
|
|
656
|
+
requested_command: tuple[str, ...],
|
|
657
|
+
policy: ExecutionPolicy,
|
|
658
|
+
cancel: threading.Event | None,
|
|
659
|
+
) -> ExecutionResult:
|
|
660
|
+
source_digest = sha256_id("files", dict(sorted(files.items())))
|
|
661
|
+
request_digest = sha256_id(
|
|
662
|
+
"request",
|
|
663
|
+
{
|
|
664
|
+
"schema": EXECUTION_SCHEMA,
|
|
665
|
+
"environment_id": self.id,
|
|
666
|
+
"operation": operation,
|
|
667
|
+
"source_digest": source_digest,
|
|
668
|
+
"entrypoint": entrypoint,
|
|
669
|
+
"targets": list(targets),
|
|
670
|
+
"command": list(requested_command),
|
|
671
|
+
"policy": policy.to_dict(),
|
|
672
|
+
"backend": self.manager.backend.name,
|
|
673
|
+
},
|
|
674
|
+
)
|
|
675
|
+
started_at = _now()
|
|
676
|
+
execution_id = sha256_id(
|
|
677
|
+
"execution",
|
|
678
|
+
{
|
|
679
|
+
"request_digest": request_digest,
|
|
680
|
+
"started_at": started_at,
|
|
681
|
+
"nonce": uuid.uuid4().hex,
|
|
682
|
+
},
|
|
683
|
+
)
|
|
684
|
+
job_parent = self.manager.store.jobs / execution_id
|
|
685
|
+
job_parent.mkdir(parents=True, exist_ok=True)
|
|
686
|
+
instance = job_parent / f"instance-{uuid.uuid4().hex}"
|
|
687
|
+
try:
|
|
688
|
+
with self.manager.store.execution_lease(self.id):
|
|
689
|
+
clone_tree(self.workspace, instance)
|
|
690
|
+
preliminary: list[BackendResult] = []
|
|
691
|
+
if operation == "check":
|
|
692
|
+
assert entrypoint is not None
|
|
693
|
+
for name, source in files.items():
|
|
694
|
+
source_path = instance / name
|
|
695
|
+
source_path.parent.mkdir(parents=True, exist_ok=True)
|
|
696
|
+
source_path.write_text(source, encoding="utf-8")
|
|
697
|
+
import_targets = _package_import_targets(files, self.lock)
|
|
698
|
+
if import_targets:
|
|
699
|
+
import_command = self.manager.toolchains.command(
|
|
700
|
+
self.lock.toolchain, "lake", "build", *import_targets
|
|
701
|
+
)
|
|
702
|
+
import_result = self.manager.backend.execute(
|
|
703
|
+
import_command,
|
|
704
|
+
cwd=instance,
|
|
705
|
+
environment=self.manager.toolchains.environment,
|
|
706
|
+
policy=policy,
|
|
707
|
+
cancel=cancel,
|
|
708
|
+
)
|
|
709
|
+
preliminary.append(import_result)
|
|
710
|
+
if import_result.exit_code:
|
|
711
|
+
result = self._result(
|
|
712
|
+
import_result,
|
|
713
|
+
command=import_command,
|
|
714
|
+
cwd=instance,
|
|
715
|
+
execution_id=execution_id,
|
|
716
|
+
request_digest=request_digest,
|
|
717
|
+
source_digest=source_digest,
|
|
718
|
+
started_at=started_at,
|
|
719
|
+
policy=policy,
|
|
720
|
+
)
|
|
721
|
+
self._record_execution(
|
|
722
|
+
result,
|
|
723
|
+
operation,
|
|
724
|
+
targets,
|
|
725
|
+
entrypoint,
|
|
726
|
+
tuple(files),
|
|
727
|
+
requested_command,
|
|
728
|
+
)
|
|
729
|
+
return result
|
|
730
|
+
for support in _support_order(files, entrypoint):
|
|
731
|
+
support_path = instance / support
|
|
732
|
+
output_path = (
|
|
733
|
+
instance / ".lake" / "build" / "lib" / "lean" / support
|
|
734
|
+
).with_suffix(".olean")
|
|
735
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
736
|
+
support_command = self.manager.toolchains.command(
|
|
737
|
+
self.lock.toolchain,
|
|
738
|
+
"lake",
|
|
739
|
+
"env",
|
|
740
|
+
"lean",
|
|
741
|
+
"-R",
|
|
742
|
+
str(instance),
|
|
743
|
+
"-o",
|
|
744
|
+
str(output_path),
|
|
745
|
+
str(support_path),
|
|
746
|
+
)
|
|
747
|
+
support_result = self.manager.backend.execute(
|
|
748
|
+
support_command,
|
|
749
|
+
cwd=instance,
|
|
750
|
+
environment=self.manager.toolchains.environment,
|
|
751
|
+
policy=policy,
|
|
752
|
+
cancel=cancel,
|
|
753
|
+
)
|
|
754
|
+
preliminary.append(support_result)
|
|
755
|
+
if support_result.exit_code:
|
|
756
|
+
result = self._result(
|
|
757
|
+
support_result,
|
|
758
|
+
command=support_command,
|
|
759
|
+
cwd=instance,
|
|
760
|
+
execution_id=execution_id,
|
|
761
|
+
request_digest=request_digest,
|
|
762
|
+
source_digest=source_digest,
|
|
763
|
+
started_at=started_at,
|
|
764
|
+
policy=policy,
|
|
765
|
+
)
|
|
766
|
+
self._record_execution(
|
|
767
|
+
result,
|
|
768
|
+
operation,
|
|
769
|
+
targets,
|
|
770
|
+
entrypoint,
|
|
771
|
+
tuple(files),
|
|
772
|
+
requested_command,
|
|
773
|
+
)
|
|
774
|
+
return result
|
|
775
|
+
source_path = instance / entrypoint
|
|
776
|
+
command = self.manager.toolchains.command(
|
|
777
|
+
self.lock.toolchain, "lake", "env", "lean", str(source_path)
|
|
778
|
+
)
|
|
779
|
+
elif operation == "build":
|
|
780
|
+
command = self.manager.toolchains.command(
|
|
781
|
+
self.lock.toolchain, "lake", "build", *targets
|
|
782
|
+
)
|
|
783
|
+
else:
|
|
784
|
+
command = self.manager.toolchains.command(
|
|
785
|
+
self.lock.toolchain, requested_command[0], *requested_command[1:]
|
|
786
|
+
)
|
|
787
|
+
raw = self.manager.backend.execute(
|
|
788
|
+
command,
|
|
789
|
+
cwd=instance,
|
|
790
|
+
environment=self.manager.toolchains.environment,
|
|
791
|
+
policy=policy,
|
|
792
|
+
cancel=cancel,
|
|
793
|
+
)
|
|
794
|
+
if operation == "check" and preliminary:
|
|
795
|
+
raw = BackendResult(
|
|
796
|
+
exit_code=raw.exit_code,
|
|
797
|
+
stdout="".join([item.stdout for item in preliminary] + [raw.stdout]),
|
|
798
|
+
stderr="".join([item.stderr for item in preliminary] + [raw.stderr]),
|
|
799
|
+
elapsed_seconds=sum(item.elapsed_seconds for item in preliminary)
|
|
800
|
+
+ raw.elapsed_seconds,
|
|
801
|
+
timed_out=raw.timed_out or any(item.timed_out for item in preliminary),
|
|
802
|
+
cancelled=raw.cancelled or any(item.cancelled for item in preliminary),
|
|
803
|
+
output_truncated=raw.output_truncated
|
|
804
|
+
or any(item.output_truncated for item in preliminary),
|
|
805
|
+
enforced_policy_fields=raw.enforced_policy_fields,
|
|
806
|
+
)
|
|
807
|
+
result = self._result(
|
|
808
|
+
raw,
|
|
809
|
+
command=command,
|
|
810
|
+
cwd=instance,
|
|
811
|
+
execution_id=execution_id,
|
|
812
|
+
request_digest=request_digest,
|
|
813
|
+
source_digest=source_digest,
|
|
814
|
+
started_at=started_at,
|
|
815
|
+
policy=policy,
|
|
816
|
+
)
|
|
817
|
+
self._record_execution(
|
|
818
|
+
result,
|
|
819
|
+
operation,
|
|
820
|
+
targets,
|
|
821
|
+
entrypoint,
|
|
822
|
+
tuple(files),
|
|
823
|
+
requested_command,
|
|
824
|
+
)
|
|
825
|
+
return result
|
|
826
|
+
finally:
|
|
827
|
+
if instance.exists():
|
|
828
|
+
shutil.rmtree(instance)
|
|
829
|
+
with suppress(OSError):
|
|
830
|
+
job_parent.rmdir()
|
|
831
|
+
|
|
832
|
+
def _result(
|
|
833
|
+
self,
|
|
834
|
+
raw: BackendResult,
|
|
835
|
+
*,
|
|
836
|
+
command: Sequence[str],
|
|
837
|
+
cwd: Path,
|
|
838
|
+
execution_id: str,
|
|
839
|
+
request_digest: str,
|
|
840
|
+
source_digest: str,
|
|
841
|
+
started_at: str,
|
|
842
|
+
policy: ExecutionPolicy,
|
|
843
|
+
) -> ExecutionResult:
|
|
844
|
+
combined = "\n".join(part for part in (raw.stdout, raw.stderr) if part)
|
|
845
|
+
diagnostics = parse_diagnostics(combined)
|
|
846
|
+
if raw.timed_out:
|
|
847
|
+
diagnostics += (error_diagnostic("Lean execution exceeded its time limit"),)
|
|
848
|
+
if raw.cancelled:
|
|
849
|
+
diagnostics += (error_diagnostic("Lean execution was cancelled"),)
|
|
850
|
+
provenance = ExecutionProvenance(
|
|
851
|
+
environment_id=self.id,
|
|
852
|
+
execution_id=execution_id,
|
|
853
|
+
request_digest=request_digest,
|
|
854
|
+
lock_id=self.lock.lock_id,
|
|
855
|
+
toolchain=self.lock.toolchain,
|
|
856
|
+
packages=tuple(
|
|
857
|
+
PackageProvenance(package.name, package.url, package.revision, package.tree_hash)
|
|
858
|
+
for package in self.lock.packages
|
|
859
|
+
),
|
|
860
|
+
platform=platform_record(),
|
|
861
|
+
backend=self.manager.backend.name,
|
|
862
|
+
requested_policy=policy.to_dict(),
|
|
863
|
+
enforced_policy_fields=raw.enforced_policy_fields,
|
|
864
|
+
source_digest=source_digest,
|
|
865
|
+
started_at=started_at,
|
|
866
|
+
)
|
|
867
|
+
return ExecutionResult(
|
|
868
|
+
ok=raw.exit_code == 0,
|
|
869
|
+
exit_code=raw.exit_code,
|
|
870
|
+
toolchain=self.lock.toolchain,
|
|
871
|
+
command=tuple(command),
|
|
872
|
+
cwd=str(cwd),
|
|
873
|
+
stdout=raw.stdout,
|
|
874
|
+
stderr=raw.stderr,
|
|
875
|
+
elapsed_seconds=raw.elapsed_seconds,
|
|
876
|
+
timed_out=raw.timed_out,
|
|
877
|
+
cancelled=raw.cancelled,
|
|
878
|
+
output_truncated=raw.output_truncated,
|
|
879
|
+
diagnostics=diagnostics,
|
|
880
|
+
provenance=provenance,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def _record_execution(
|
|
884
|
+
self,
|
|
885
|
+
result: ExecutionResult,
|
|
886
|
+
operation: str,
|
|
887
|
+
targets: tuple[str, ...],
|
|
888
|
+
entrypoint: str | None,
|
|
889
|
+
files: tuple[str, ...],
|
|
890
|
+
requested_command: tuple[str, ...],
|
|
891
|
+
) -> None:
|
|
892
|
+
assert result.execution_id is not None
|
|
893
|
+
write_json_atomic(
|
|
894
|
+
self.manager.store.executions / f"{result.execution_id}.json",
|
|
895
|
+
{
|
|
896
|
+
"schema": EXECUTION_SCHEMA,
|
|
897
|
+
"operation": operation,
|
|
898
|
+
"targets": list(targets),
|
|
899
|
+
"entrypoint": entrypoint,
|
|
900
|
+
"files": list(files),
|
|
901
|
+
"command": list(requested_command),
|
|
902
|
+
"result": result.to_dict(),
|
|
903
|
+
},
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
class EnvironmentManager:
|
|
908
|
+
def __init__(
|
|
909
|
+
self,
|
|
910
|
+
store: EnvironmentStore,
|
|
911
|
+
toolchains: ToolchainManager,
|
|
912
|
+
backend: Backend,
|
|
913
|
+
events: EventEmitter | None = None,
|
|
914
|
+
) -> None:
|
|
915
|
+
self.store = store
|
|
916
|
+
self.toolchains = toolchains
|
|
917
|
+
self.backend = backend
|
|
918
|
+
self.events = events or EventEmitter()
|
|
919
|
+
|
|
920
|
+
def ensure(
|
|
921
|
+
self,
|
|
922
|
+
lock: EnvironmentLock,
|
|
923
|
+
*,
|
|
924
|
+
name: str | None = None,
|
|
925
|
+
build_profile: str = "release",
|
|
926
|
+
) -> Environment:
|
|
927
|
+
self.store.publish_lock(lock)
|
|
928
|
+
environment_id = environment_identity(lock, build_profile)
|
|
929
|
+
destination = self.store.environment_path(environment_id)
|
|
930
|
+
self.events.emit(
|
|
931
|
+
"environment.ensure_started",
|
|
932
|
+
"Ensuring published environment",
|
|
933
|
+
environment_id=environment_id,
|
|
934
|
+
)
|
|
935
|
+
with FileLock(self.store.lock_dir / f"{environment_id}.lock", timeout=1800):
|
|
936
|
+
if not destination.is_dir():
|
|
937
|
+
self._ensure_sources(lock)
|
|
938
|
+
self._materialize(lock, environment_id, destination, build_profile)
|
|
939
|
+
else:
|
|
940
|
+
self.events.emit(
|
|
941
|
+
"environment.cache_hit",
|
|
942
|
+
"Reusing published environment",
|
|
943
|
+
environment_id=environment_id,
|
|
944
|
+
)
|
|
945
|
+
environment = self.open(environment_id)
|
|
946
|
+
if name:
|
|
947
|
+
self.store.set_alias(name, environment_id)
|
|
948
|
+
self.events.emit(
|
|
949
|
+
"environment.ready",
|
|
950
|
+
"Environment is ready",
|
|
951
|
+
environment_id=environment_id,
|
|
952
|
+
name=name,
|
|
953
|
+
)
|
|
954
|
+
return environment
|
|
955
|
+
|
|
956
|
+
def open(self, identifier: str) -> Environment:
|
|
957
|
+
environment_id = self.store.resolve_identifier(identifier)
|
|
958
|
+
root = self.store.environment_path(environment_id)
|
|
959
|
+
record_path = root / "metadata.json"
|
|
960
|
+
if not record_path.is_file():
|
|
961
|
+
raise EnvironmentError(f"published environment has no metadata: {environment_id}")
|
|
962
|
+
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
963
|
+
if record.get("schema") != ENVIRONMENT_SCHEMA or record.get("status") != "ready":
|
|
964
|
+
raise EnvironmentError(f"environment is not ready: {environment_id}")
|
|
965
|
+
lock = self.store.load_lock(str(record["lock_id"]))
|
|
966
|
+
expected = environment_identity(lock, str(record["build_profile"]))
|
|
967
|
+
if expected != environment_id or record.get("environment_id") != environment_id:
|
|
968
|
+
raise EnvironmentError(f"environment identity mismatch: {environment_id}")
|
|
969
|
+
if not (root / "workspace" / ".lake" / "build").is_dir():
|
|
970
|
+
raise EnvironmentError(f"environment build artifacts are missing: {environment_id}")
|
|
971
|
+
self.store.touch_environment(environment_id)
|
|
972
|
+
return Environment(self, environment_id, lock, root, record)
|
|
973
|
+
|
|
974
|
+
def _materialize(
|
|
975
|
+
self,
|
|
976
|
+
lock: EnvironmentLock,
|
|
977
|
+
environment_id: str,
|
|
978
|
+
destination: Path,
|
|
979
|
+
build_profile: str,
|
|
980
|
+
) -> None:
|
|
981
|
+
stage = self.store.environments / f".staging-{os.getpid()}-{uuid.uuid4().hex}"
|
|
982
|
+
workspace = stage / "workspace"
|
|
983
|
+
try:
|
|
984
|
+
self.events.emit(
|
|
985
|
+
"environment.build_started",
|
|
986
|
+
"Building environment",
|
|
987
|
+
environment_id=environment_id,
|
|
988
|
+
)
|
|
989
|
+
workspace.mkdir(parents=True)
|
|
990
|
+
(workspace / "lean-toolchain").write_text(lock.toolchain + "\n", encoding="utf-8")
|
|
991
|
+
(workspace / "lakefile.toml").write_text(lock.root_lakefile, encoding="utf-8")
|
|
992
|
+
(workspace / f"{ROOT_MODULE}.lean").write_text(lock.root_module, encoding="utf-8")
|
|
993
|
+
write_json_atomic(workspace / "lake-manifest.json", lock.manifest)
|
|
994
|
+
packages_dir = workspace / str(lock.manifest.get("packagesDir", ".lake/packages"))
|
|
995
|
+
packages_dir.mkdir(parents=True, exist_ok=True)
|
|
996
|
+
for package in lock.packages:
|
|
997
|
+
source = self.store.source_path(package.source_id)
|
|
998
|
+
if not source.is_dir():
|
|
999
|
+
raise MaterializationError(
|
|
1000
|
+
f"locked package source is unavailable: {package.name}",
|
|
1001
|
+
phase="acquisition",
|
|
1002
|
+
)
|
|
1003
|
+
clone_tree(source, packages_dir / package.name)
|
|
1004
|
+
build_policy = ExecutionPolicy(timeout_seconds=1800, max_output_bytes=10_000_000)
|
|
1005
|
+
hydration: list[dict[str, Any]] = []
|
|
1006
|
+
for package in lock.packages:
|
|
1007
|
+
if not package.artifact_command:
|
|
1008
|
+
continue
|
|
1009
|
+
self.events.emit(
|
|
1010
|
+
"artifact.hydration_started",
|
|
1011
|
+
f"Hydrating artifacts for {package.name}",
|
|
1012
|
+
package=package.name,
|
|
1013
|
+
)
|
|
1014
|
+
command = list(package.artifact_command)
|
|
1015
|
+
if command[0] in {"lake", "lean"}:
|
|
1016
|
+
command = self.toolchains.command(lock.toolchain, command[0], *command[1:])
|
|
1017
|
+
result = self.backend.execute(
|
|
1018
|
+
command,
|
|
1019
|
+
cwd=workspace,
|
|
1020
|
+
environment=self.toolchains.environment,
|
|
1021
|
+
policy=build_policy,
|
|
1022
|
+
)
|
|
1023
|
+
hydration.append(
|
|
1024
|
+
{
|
|
1025
|
+
"package": package.name,
|
|
1026
|
+
"command": command,
|
|
1027
|
+
"exit_code": result.exit_code,
|
|
1028
|
+
"output": result.stdout + result.stderr,
|
|
1029
|
+
}
|
|
1030
|
+
)
|
|
1031
|
+
if result.exit_code:
|
|
1032
|
+
raise MaterializationError(
|
|
1033
|
+
f"artifact hydration failed for {package.name}",
|
|
1034
|
+
phase="artifact-hydration",
|
|
1035
|
+
command=tuple(command),
|
|
1036
|
+
exit_code=result.exit_code,
|
|
1037
|
+
output=result.stdout + result.stderr,
|
|
1038
|
+
)
|
|
1039
|
+
command = self.toolchains.command(lock.toolchain, "lake", "build")
|
|
1040
|
+
result = self.backend.execute(
|
|
1041
|
+
command,
|
|
1042
|
+
cwd=workspace,
|
|
1043
|
+
environment=self.toolchains.environment,
|
|
1044
|
+
policy=build_policy,
|
|
1045
|
+
)
|
|
1046
|
+
if result.exit_code:
|
|
1047
|
+
raise MaterializationError(
|
|
1048
|
+
"environment build failed",
|
|
1049
|
+
phase="build",
|
|
1050
|
+
command=tuple(command),
|
|
1051
|
+
exit_code=result.exit_code,
|
|
1052
|
+
output=result.stdout + result.stderr,
|
|
1053
|
+
)
|
|
1054
|
+
metadata = {
|
|
1055
|
+
"schema": ENVIRONMENT_SCHEMA,
|
|
1056
|
+
"environment_id": environment_id,
|
|
1057
|
+
"lock_id": lock.lock_id,
|
|
1058
|
+
"toolchain": lock.toolchain,
|
|
1059
|
+
"platform": platform_record(),
|
|
1060
|
+
"build_profile": build_profile,
|
|
1061
|
+
"status": "ready",
|
|
1062
|
+
"created_at": _now(),
|
|
1063
|
+
"hydration": hydration,
|
|
1064
|
+
"build": {
|
|
1065
|
+
"command": command,
|
|
1066
|
+
"exit_code": result.exit_code,
|
|
1067
|
+
"elapsed_seconds": result.elapsed_seconds,
|
|
1068
|
+
"output_truncated": result.output_truncated,
|
|
1069
|
+
},
|
|
1070
|
+
}
|
|
1071
|
+
write_json_atomic(stage / "metadata.json", metadata)
|
|
1072
|
+
stage.replace(destination)
|
|
1073
|
+
self.events.emit(
|
|
1074
|
+
"environment.published",
|
|
1075
|
+
"Published built environment",
|
|
1076
|
+
environment_id=environment_id,
|
|
1077
|
+
)
|
|
1078
|
+
finally:
|
|
1079
|
+
if stage.exists():
|
|
1080
|
+
shutil.rmtree(stage)
|
|
1081
|
+
|
|
1082
|
+
def _ensure_sources(self, lock: EnvironmentLock) -> None:
|
|
1083
|
+
"""Acquire exact locked sources without invoking Lake resolution."""
|
|
1084
|
+
acquisition_root = self.store.home / "acquisition"
|
|
1085
|
+
acquisition_root.mkdir(parents=True, exist_ok=True)
|
|
1086
|
+
for package in lock.packages:
|
|
1087
|
+
if self.store.source_path(package.source_id).is_dir():
|
|
1088
|
+
self.store.validate_source(
|
|
1089
|
+
package.source_id,
|
|
1090
|
+
url=package.url,
|
|
1091
|
+
revision=package.revision,
|
|
1092
|
+
tree_hash=package.tree_hash,
|
|
1093
|
+
)
|
|
1094
|
+
self.events.emit(
|
|
1095
|
+
"source.cache_hit",
|
|
1096
|
+
f"Reusing source for {package.name}",
|
|
1097
|
+
package=package.name,
|
|
1098
|
+
source_id=package.source_id,
|
|
1099
|
+
)
|
|
1100
|
+
continue
|
|
1101
|
+
self.events.emit(
|
|
1102
|
+
"source.fetch_started",
|
|
1103
|
+
f"Fetching {package.name}",
|
|
1104
|
+
package=package.name,
|
|
1105
|
+
revision=package.revision,
|
|
1106
|
+
)
|
|
1107
|
+
with tempfile.TemporaryDirectory(
|
|
1108
|
+
prefix=f"{package.name}-", dir=acquisition_root
|
|
1109
|
+
) as raw:
|
|
1110
|
+
checkout = Path(raw)
|
|
1111
|
+
commands = [
|
|
1112
|
+
["git", "init", "-q"],
|
|
1113
|
+
["git", "remote", "add", "origin", package.url],
|
|
1114
|
+
[
|
|
1115
|
+
"git",
|
|
1116
|
+
"fetch",
|
|
1117
|
+
"--depth",
|
|
1118
|
+
"1",
|
|
1119
|
+
"--filter=blob:none",
|
|
1120
|
+
"origin",
|
|
1121
|
+
package.revision,
|
|
1122
|
+
],
|
|
1123
|
+
["git", "checkout", "--detach", "FETCH_HEAD"],
|
|
1124
|
+
]
|
|
1125
|
+
for command in commands:
|
|
1126
|
+
completed = subprocess.run(
|
|
1127
|
+
command,
|
|
1128
|
+
cwd=checkout,
|
|
1129
|
+
text=True,
|
|
1130
|
+
capture_output=True,
|
|
1131
|
+
check=False,
|
|
1132
|
+
)
|
|
1133
|
+
if completed.returncode:
|
|
1134
|
+
raise MaterializationError(
|
|
1135
|
+
f"could not acquire locked source {package.name}",
|
|
1136
|
+
phase="acquisition",
|
|
1137
|
+
command=tuple(command),
|
|
1138
|
+
exit_code=completed.returncode,
|
|
1139
|
+
output=completed.stdout + completed.stderr,
|
|
1140
|
+
)
|
|
1141
|
+
tree = subprocess.run(
|
|
1142
|
+
["git", "rev-parse", "HEAD^{tree}"],
|
|
1143
|
+
cwd=checkout,
|
|
1144
|
+
text=True,
|
|
1145
|
+
capture_output=True,
|
|
1146
|
+
check=True,
|
|
1147
|
+
).stdout.strip()
|
|
1148
|
+
if tree != package.tree_hash:
|
|
1149
|
+
raise MaterializationError(
|
|
1150
|
+
f"acquired Git tree does not match lock for {package.name}",
|
|
1151
|
+
phase="source-validation",
|
|
1152
|
+
)
|
|
1153
|
+
self.store.publish_source(
|
|
1154
|
+
checkout,
|
|
1155
|
+
package.source_id,
|
|
1156
|
+
{
|
|
1157
|
+
"schema": "lean-runtime-git-source/1",
|
|
1158
|
+
"source_id": package.source_id,
|
|
1159
|
+
"name": package.name,
|
|
1160
|
+
"url": package.url,
|
|
1161
|
+
"revision": package.revision,
|
|
1162
|
+
"tree_hash": package.tree_hash,
|
|
1163
|
+
},
|
|
1164
|
+
)
|
|
1165
|
+
self.store.validate_source(
|
|
1166
|
+
package.source_id,
|
|
1167
|
+
url=package.url,
|
|
1168
|
+
revision=package.revision,
|
|
1169
|
+
tree_hash=package.tree_hash,
|
|
1170
|
+
)
|