lean-lockstep 0.1.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_lockstep-0.1.0.dist-info/METADATA +218 -0
- lean_lockstep-0.1.0.dist-info/RECORD +71 -0
- lean_lockstep-0.1.0.dist-info/WHEEL +5 -0
- lean_lockstep-0.1.0.dist-info/entry_points.txt +3 -0
- lean_lockstep-0.1.0.dist-info/licenses/LICENSE +22 -0
- lean_lockstep-0.1.0.dist-info/top_level.txt +1 -0
- lockstep/__init__.py +17 -0
- lockstep/__main__.py +3 -0
- lockstep/allowlist.py +40 -0
- lockstep/artifact_cache.py +515 -0
- lockstep/artifacts.py +185 -0
- lockstep/automation_schema.py +30 -0
- lockstep/build.py +468 -0
- lockstep/bundles.py +37 -0
- lockstep/cli.py +148 -0
- lockstep/commands/__init__.py +28 -0
- lockstep/commands/audit.py +46 -0
- lockstep/commands/automation.py +84 -0
- lockstep/commands/build.py +61 -0
- lockstep/commands/cache.py +58 -0
- lockstep/commands/check.py +81 -0
- lockstep/commands/claims.py +62 -0
- lockstep/commands/close.py +193 -0
- lockstep/commands/doctor.py +86 -0
- lockstep/commands/extract.py +169 -0
- lockstep/commands/falsify.py +108 -0
- lockstep/commands/index.py +16 -0
- lockstep/commands/jobs.py +188 -0
- lockstep/commands/layers.py +52 -0
- lockstep/commands/library.py +67 -0
- lockstep/commands/lock.py +58 -0
- lockstep/commands/log.py +132 -0
- lockstep/commands/node.py +429 -0
- lockstep/commands/note.py +76 -0
- lockstep/commands/probe.py +11 -0
- lockstep/commands/proof.py +31 -0
- lockstep/commands/ready.py +48 -0
- lockstep/commands/rules.py +61 -0
- lockstep/commands/search.py +37 -0
- lockstep/commands/status.py +172 -0
- lockstep/config.py +489 -0
- lockstep/context.py +33 -0
- lockstep/data/AuditCandidate.lean +18 -0
- lockstep/data/CacheArtifacts.lean +37 -0
- lockstep/data/Extract.lean.tmpl +362 -0
- lockstep/data/node.lean.tmpl +9 -0
- lockstep/delivery.py +45 -0
- lockstep/errors.py +28 -0
- lockstep/finalize.py +102 -0
- lockstep/gitutil.py +90 -0
- lockstep/hooks.py +143 -0
- lockstep/jobs.py +349 -0
- lockstep/layers.py +156 -0
- lockstep/lean.py +107 -0
- lockstep/library.py +188 -0
- lockstep/lock.py +68 -0
- lockstep/mcp_server.py +84 -0
- lockstep/metrics.py +12 -0
- lockstep/modules.py +197 -0
- lockstep/nodes.py +475 -0
- lockstep/operations.py +265 -0
- lockstep/output.py +45 -0
- lockstep/probes.py +72 -0
- lockstep/proofs.py +250 -0
- lockstep/resources.py +100 -0
- lockstep/shadow_index.py +47 -0
- lockstep/spec.py +36 -0
- lockstep/stage_runner.py +47 -0
- lockstep/storage.py +186 -0
- lockstep/store.py +338 -0
- lockstep/tags.py +84 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""Verified sharing of immutable Lean certificate build artifacts.
|
|
2
|
+
|
|
3
|
+
Lake owns the content-addressed blob format. This module adds the stronger
|
|
4
|
+
SHA-256 identity check and atomic adoption needed to deduplicate already-built
|
|
5
|
+
outputs without manufacturing Lake input-to-output mappings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import signal
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
from lockstep import DATA
|
|
22
|
+
from lockstep.build import lean_compilers_running
|
|
23
|
+
from lockstep.config import Config, load_config
|
|
24
|
+
from lockstep.errors import CheckFailed, UsageError
|
|
25
|
+
from lockstep.modules import module_to_path, scan_modules
|
|
26
|
+
from lockstep.storage import StorageTarget, exclusive_storage_maintenance
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FACET_DIRS = {
|
|
30
|
+
"olean": Path(".lake/build/lib/lean"),
|
|
31
|
+
"olean.server": Path(".lake/build/lib/lean"),
|
|
32
|
+
"olean.private": Path(".lake/build/lib/lean"),
|
|
33
|
+
"ilean": Path(".lake/build/lib/lean"),
|
|
34
|
+
"ir": Path(".lake/build/lib/lean"),
|
|
35
|
+
"c": Path(".lake/build/ir"),
|
|
36
|
+
"bc": Path(".lake/build/ir"),
|
|
37
|
+
}
|
|
38
|
+
DEFAULT_FACETS = tuple(FACET_DIRS)
|
|
39
|
+
SCHEMA = "lockstep-artifact-share-v1"
|
|
40
|
+
SUPPORTED_TOOLCHAINS = {
|
|
41
|
+
"leanprover/lean4:v4.29.1": "lean-v4.29.1-f72c35b",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class FileIdentity:
|
|
47
|
+
device: int
|
|
48
|
+
inode: int
|
|
49
|
+
size: int
|
|
50
|
+
mtime_ns: int
|
|
51
|
+
ctime_ns: int
|
|
52
|
+
links: int
|
|
53
|
+
allocated_bytes: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Candidate:
|
|
58
|
+
module: str
|
|
59
|
+
facet: str
|
|
60
|
+
relative_output: str
|
|
61
|
+
primary: Path
|
|
62
|
+
other: Path
|
|
63
|
+
size: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _identity(path: Path) -> FileIdentity:
|
|
67
|
+
info = path.lstat()
|
|
68
|
+
if not stat.S_ISREG(info.st_mode):
|
|
69
|
+
kind = "symlink" if stat.S_ISLNK(info.st_mode) else "non-regular file"
|
|
70
|
+
raise CheckFailed(f"refusing {kind}: {path}")
|
|
71
|
+
return FileIdentity(
|
|
72
|
+
info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns,
|
|
73
|
+
info.st_nlink, info.st_blocks * 512,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _absolute_without_symlinks(path: Path) -> Path:
|
|
78
|
+
return Path(os.path.abspath(os.fspath(path.expanduser())))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reject_symlink_ancestors(path: Path, boundary: Path | None = None) -> None:
|
|
82
|
+
"""Reject an existing symlink in ``path`` or its parents down to ``boundary``.
|
|
83
|
+
|
|
84
|
+
``Path.resolve`` is intentionally not used: resolving first would hide the
|
|
85
|
+
condition this check protects against.
|
|
86
|
+
"""
|
|
87
|
+
path = _absolute_without_symlinks(path)
|
|
88
|
+
boundary = _absolute_without_symlinks(boundary or Path(path.anchor))
|
|
89
|
+
try:
|
|
90
|
+
relative = path.relative_to(boundary)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise CheckFailed(f"path escapes its trusted boundary {boundary}: {path}") from exc
|
|
93
|
+
current = boundary
|
|
94
|
+
for part in ("", *relative.parts):
|
|
95
|
+
if part:
|
|
96
|
+
current = current / part
|
|
97
|
+
try:
|
|
98
|
+
info = current.lstat()
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
continue
|
|
101
|
+
if stat.S_ISLNK(info.st_mode):
|
|
102
|
+
raise CheckFailed(f"refusing symlink or symlink ancestor: {current}")
|
|
103
|
+
if current != path and not stat.S_ISDIR(info.st_mode):
|
|
104
|
+
raise CheckFailed(f"refusing non-directory path ancestor: {current}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sha256_file(path: Path) -> str:
|
|
108
|
+
digest = hashlib.sha256()
|
|
109
|
+
with path.open("rb", buffering=0) as stream:
|
|
110
|
+
for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
|
|
111
|
+
digest.update(block)
|
|
112
|
+
return digest.hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _toolchain(root: Path) -> str:
|
|
116
|
+
path = root / "lean-toolchain"
|
|
117
|
+
if not path.is_file() or path.is_symlink():
|
|
118
|
+
raise CheckFailed(f"missing or unsafe lean-toolchain: {path}")
|
|
119
|
+
return path.read_text().strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def supported_toolchain(root: Path) -> tuple[str, str]:
|
|
123
|
+
toolchain = _toolchain(root)
|
|
124
|
+
try:
|
|
125
|
+
namespace = SUPPORTED_TOOLCHAINS[toolchain]
|
|
126
|
+
except KeyError as exc:
|
|
127
|
+
raise CheckFailed(f"unsupported Lean toolchain for artifact sharing: {toolchain}") from exc
|
|
128
|
+
return toolchain, namespace
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_cache_root(cfg: Config) -> Path:
|
|
132
|
+
_, namespace = supported_toolchain(cfg.root)
|
|
133
|
+
return cfg.state_home / "lake-artifacts" / namespace
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate_roots(cfg: Config, other_root: Path) -> Config:
|
|
137
|
+
primary = cfg.root.resolve()
|
|
138
|
+
other = other_root.resolve()
|
|
139
|
+
if primary == other:
|
|
140
|
+
raise UsageError("the other checkout must differ from the current checkout")
|
|
141
|
+
other_cfg = load_config(other)
|
|
142
|
+
other_cfg.root = other
|
|
143
|
+
if cfg.project_id != other_cfg.project_id:
|
|
144
|
+
raise CheckFailed(f"project mismatch: {cfg.project_id} != {other_cfg.project_id}")
|
|
145
|
+
if cfg.db_path.resolve() != other_cfg.db_path.resolve():
|
|
146
|
+
raise CheckFailed("checkouts must share the harness database so every writer honors the same storage reservations")
|
|
147
|
+
primary_toolchain, _ = supported_toolchain(primary)
|
|
148
|
+
other_toolchain, _ = supported_toolchain(other)
|
|
149
|
+
if primary_toolchain != other_toolchain:
|
|
150
|
+
raise CheckFailed("Lean toolchain mismatch between checkouts")
|
|
151
|
+
if primary.stat().st_dev != other.stat().st_dev:
|
|
152
|
+
raise CheckFailed("checkouts are on different filesystems; hard-link sharing is unavailable")
|
|
153
|
+
return other_cfg
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _output_path(root: Path, module: str, facet: str) -> Path:
|
|
157
|
+
base = (root / FACET_DIRS[facet] / module_to_path(module)).with_suffix("")
|
|
158
|
+
return Path(str(base) + "." + facet)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def certificate_candidates(
|
|
162
|
+
cfg: Config, other_cfg: Config, facets: Iterable[str] = DEFAULT_FACETS
|
|
163
|
+
) -> tuple[list[Candidate], dict[str, int]]:
|
|
164
|
+
selected = tuple(dict.fromkeys(facets))
|
|
165
|
+
unknown = sorted(set(selected) - set(FACET_DIRS))
|
|
166
|
+
if unknown:
|
|
167
|
+
raise UsageError("unsupported compiled facet(s): " + ", ".join(unknown))
|
|
168
|
+
primary_modules = scan_modules(cfg)
|
|
169
|
+
other_modules = scan_modules(other_cfg)
|
|
170
|
+
layer = cfg.certificates_layer
|
|
171
|
+
other_layer = other_cfg.certificates_layer
|
|
172
|
+
generated = {
|
|
173
|
+
name for name, mod in primary_modules.items()
|
|
174
|
+
if mod.generated and mod.layer == layer
|
|
175
|
+
and name in other_modules and other_modules[name].generated
|
|
176
|
+
and other_modules[name].layer == other_layer
|
|
177
|
+
}
|
|
178
|
+
stats = {"generated_modules": len(generated), "missing": 0, "size_mismatch": 0, "already_shared": 0}
|
|
179
|
+
candidates: list[Candidate] = []
|
|
180
|
+
for module in sorted(generated):
|
|
181
|
+
for facet in selected:
|
|
182
|
+
primary = _output_path(cfg.root, module, facet)
|
|
183
|
+
other = _output_path(other_cfg.root, module, facet)
|
|
184
|
+
reject_symlink_ancestors(primary, cfg.root)
|
|
185
|
+
reject_symlink_ancestors(other, other_cfg.root)
|
|
186
|
+
if not primary.exists() or not other.exists():
|
|
187
|
+
stats["missing"] += 1
|
|
188
|
+
continue
|
|
189
|
+
p_id = _identity(primary)
|
|
190
|
+
o_id = _identity(other)
|
|
191
|
+
if p_id.size != o_id.size:
|
|
192
|
+
stats["size_mismatch"] += 1
|
|
193
|
+
continue
|
|
194
|
+
if (p_id.device, p_id.inode) == (o_id.device, o_id.inode):
|
|
195
|
+
stats["already_shared"] += 1
|
|
196
|
+
continue
|
|
197
|
+
rel = str(primary.relative_to(cfg.root))
|
|
198
|
+
candidates.append(Candidate(module, facet, rel, primary, other, p_id.size))
|
|
199
|
+
stats["candidates"] = len(candidates)
|
|
200
|
+
stats["candidate_bytes"] = sum(item.size for item in candidates)
|
|
201
|
+
return candidates, stats
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Receipt:
|
|
205
|
+
def __init__(self, path: Path, header: dict[str, object]):
|
|
206
|
+
self.path = path
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
if path.exists() and path.stat().st_size:
|
|
209
|
+
rows = self._repair_and_read()
|
|
210
|
+
if not rows:
|
|
211
|
+
self.append(header)
|
|
212
|
+
return
|
|
213
|
+
first = next((row for row in rows if row), "")
|
|
214
|
+
try:
|
|
215
|
+
previous = json.loads(first)
|
|
216
|
+
except json.JSONDecodeError as exc:
|
|
217
|
+
raise CheckFailed(f"invalid existing receipt {path}: {exc}") from exc
|
|
218
|
+
keys = ("schema", "primary", "other", "cache", "toolchain", "facets")
|
|
219
|
+
if any(previous.get(key) != header.get(key) for key in keys):
|
|
220
|
+
raise CheckFailed(f"receipt parameters do not match this invocation: {path}")
|
|
221
|
+
else:
|
|
222
|
+
self.append(header)
|
|
223
|
+
|
|
224
|
+
def _repair_and_read(self) -> list[str]:
|
|
225
|
+
"""Drop only an invalid torn tail and make a valid unterminated tail append-safe."""
|
|
226
|
+
data = self.path.read_bytes()
|
|
227
|
+
lines = data.splitlines(keepends=True)
|
|
228
|
+
valid: list[str] = []
|
|
229
|
+
offset = 0
|
|
230
|
+
truncate_at: int | None = None
|
|
231
|
+
for index, raw in enumerate(lines):
|
|
232
|
+
text = raw.rstrip(b"\r\n").decode(errors="strict")
|
|
233
|
+
if not text:
|
|
234
|
+
offset += len(raw)
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
json.loads(text)
|
|
238
|
+
except json.JSONDecodeError as exc:
|
|
239
|
+
if index != len(lines) - 1:
|
|
240
|
+
raise CheckFailed(f"invalid receipt record in {self.path}: {exc}") from exc
|
|
241
|
+
truncate_at = offset
|
|
242
|
+
break
|
|
243
|
+
valid.append(text)
|
|
244
|
+
offset += len(raw)
|
|
245
|
+
if truncate_at is not None:
|
|
246
|
+
with self.path.open("r+b") as stream:
|
|
247
|
+
stream.truncate(truncate_at)
|
|
248
|
+
stream.flush()
|
|
249
|
+
os.fsync(stream.fileno())
|
|
250
|
+
data = data[:truncate_at]
|
|
251
|
+
if data and not data.endswith(b"\n"):
|
|
252
|
+
fd = os.open(self.path, os.O_WRONLY | os.O_APPEND)
|
|
253
|
+
try:
|
|
254
|
+
os.write(fd, b"\n")
|
|
255
|
+
os.fsync(fd)
|
|
256
|
+
finally:
|
|
257
|
+
os.close(fd)
|
|
258
|
+
return valid
|
|
259
|
+
|
|
260
|
+
def append(self, record: dict[str, object]) -> None:
|
|
261
|
+
encoded = json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n"
|
|
262
|
+
fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
263
|
+
try:
|
|
264
|
+
os.write(fd, encoded.encode())
|
|
265
|
+
os.fsync(fd)
|
|
266
|
+
finally:
|
|
267
|
+
os.close(fd)
|
|
268
|
+
|
|
269
|
+
def adopted(self) -> set[tuple[str, str]]:
|
|
270
|
+
out: set[tuple[str, str]] = set()
|
|
271
|
+
for line in self._repair_and_read()[1:]:
|
|
272
|
+
try:
|
|
273
|
+
row = json.loads(line)
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
break # an interrupted final append is ignored
|
|
276
|
+
if row.get("event") == "adopted":
|
|
277
|
+
out.add((str(row["module"]), str(row["facet"])))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def verify_candidates(candidates: Iterable[Candidate], receipt: Receipt) -> list[tuple[Candidate, str]]:
|
|
282
|
+
verified: list[tuple[Candidate, str]] = []
|
|
283
|
+
for item in candidates:
|
|
284
|
+
primary_before = _identity(item.primary)
|
|
285
|
+
other_before = _identity(item.other)
|
|
286
|
+
primary_sha = sha256_file(item.primary)
|
|
287
|
+
other_sha = sha256_file(item.other)
|
|
288
|
+
if _identity(item.primary) != primary_before or _identity(item.other) != other_before:
|
|
289
|
+
raise CheckFailed(f"artifact changed while hashing: {item.relative_output}")
|
|
290
|
+
if primary_sha != other_sha:
|
|
291
|
+
receipt.append({"event": "mismatch", "module": item.module, "facet": item.facet,
|
|
292
|
+
"bytes": item.size, "primary_sha256": primary_sha, "other_sha256": other_sha})
|
|
293
|
+
continue
|
|
294
|
+
receipt.append({"event": "verified", "module": item.module, "facet": item.facet,
|
|
295
|
+
"relative_output": item.relative_output, "bytes": item.size, "sha256": primary_sha,
|
|
296
|
+
"primary_identity": asdict(primary_before), "other_identity": asdict(other_before)})
|
|
297
|
+
verified.append((item, primary_sha))
|
|
298
|
+
return verified
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _driver_path() -> Path:
|
|
302
|
+
return DATA / "CacheArtifacts.lean"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def save_native_artifacts(
|
|
306
|
+
cfg: Config, cache_root: Path, verified: list[tuple[Candidate, str]], work: Path,
|
|
307
|
+
*, timeout_s: int = 21600,
|
|
308
|
+
) -> dict[str, Path]:
|
|
309
|
+
manifest = work / "native-input.tsv"
|
|
310
|
+
output = work / "native-output.tsv"
|
|
311
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
for item, _ in verified:
|
|
313
|
+
if any(char in str(item.primary) for char in ("\t", "\n", "\r")):
|
|
314
|
+
raise CheckFailed(f"unsupported control character in artifact path: {item.primary}")
|
|
315
|
+
manifest.write_text("".join(f"{item.primary}\t{item.facet}\n" for item, _ in verified))
|
|
316
|
+
argv = [cfg.lake, "env", "lean", "--run", str(_driver_path()), str(cache_root), str(manifest), str(output)]
|
|
317
|
+
try:
|
|
318
|
+
proc = subprocess.Popen(
|
|
319
|
+
argv, cwd=cfg.root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
320
|
+
start_new_session=True,
|
|
321
|
+
)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
raise CheckFailed(f"could not start Lake artifact writer: {exc}") from exc
|
|
324
|
+
|
|
325
|
+
def stop_group() -> None:
|
|
326
|
+
try:
|
|
327
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
328
|
+
except ProcessLookupError:
|
|
329
|
+
proc.wait()
|
|
330
|
+
return
|
|
331
|
+
deadline = time.monotonic() + 10
|
|
332
|
+
while time.monotonic() < deadline:
|
|
333
|
+
try:
|
|
334
|
+
os.killpg(proc.pid, 0)
|
|
335
|
+
except ProcessLookupError:
|
|
336
|
+
proc.wait()
|
|
337
|
+
return
|
|
338
|
+
time.sleep(0.05)
|
|
339
|
+
try:
|
|
340
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
341
|
+
except ProcessLookupError:
|
|
342
|
+
pass
|
|
343
|
+
proc.wait()
|
|
344
|
+
# Do not return to the storage-reservation context while a descendant
|
|
345
|
+
# from the cache-writing process group can still run.
|
|
346
|
+
while True:
|
|
347
|
+
try:
|
|
348
|
+
os.killpg(proc.pid, 0)
|
|
349
|
+
except ProcessLookupError:
|
|
350
|
+
return
|
|
351
|
+
time.sleep(0.05)
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
stdout, _ = proc.communicate(timeout=timeout_s)
|
|
355
|
+
except subprocess.TimeoutExpired as exc:
|
|
356
|
+
stop_group()
|
|
357
|
+
raise CheckFailed(f"Lake artifact writer exceeded {timeout_s}s and its process group was stopped") from exc
|
|
358
|
+
except BaseException:
|
|
359
|
+
stop_group()
|
|
360
|
+
raise
|
|
361
|
+
if proc.returncode:
|
|
362
|
+
raise CheckFailed(f"Lake artifact writer failed ({proc.returncode}):\n{stdout[-4000:]}")
|
|
363
|
+
saved: dict[str, Path] = {}
|
|
364
|
+
for line in output.read_text().splitlines():
|
|
365
|
+
parts = line.split("\t")
|
|
366
|
+
if len(parts) != 2:
|
|
367
|
+
raise CheckFailed(f"invalid native artifact writer output: {line}")
|
|
368
|
+
local, relative = parts
|
|
369
|
+
saved[local] = cache_root / "artifacts" / relative
|
|
370
|
+
if set(saved) != {str(item.primary) for item, _ in verified}:
|
|
371
|
+
raise CheckFailed("native artifact writer returned an incomplete or unexpected manifest")
|
|
372
|
+
return saved
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def atomic_adopt(path: Path, blob: Path, expected_sha256: str) -> tuple[FileIdentity, FileIdentity]:
|
|
376
|
+
blob_mode = blob.lstat().st_mode
|
|
377
|
+
if blob_mode & 0o222:
|
|
378
|
+
os.chmod(blob, 0o444)
|
|
379
|
+
before = _identity(path)
|
|
380
|
+
blob_id = _identity(blob)
|
|
381
|
+
blob_content_id = (
|
|
382
|
+
blob_id.device, blob_id.inode, blob_id.size, blob_id.mtime_ns, blob_id.allocated_bytes
|
|
383
|
+
)
|
|
384
|
+
if before.device != blob_id.device:
|
|
385
|
+
raise CheckFailed(f"artifact and cache blob are on different filesystems: {path}")
|
|
386
|
+
if before.size != blob_id.size or sha256_file(blob) != expected_sha256 or _identity(blob) != blob_id:
|
|
387
|
+
raise CheckFailed(f"native cache hash collision or corrupt blob for {path}")
|
|
388
|
+
if sha256_file(path) != expected_sha256 or _identity(path) != before:
|
|
389
|
+
raise CheckFailed(f"artifact changed before adoption: {path}")
|
|
390
|
+
if (before.device, before.inode) == (blob_id.device, blob_id.inode):
|
|
391
|
+
return before, before
|
|
392
|
+
temp = path.with_name(f".{path.name}.lockstep-share-{os.getpid()}-{time.time_ns()}")
|
|
393
|
+
try:
|
|
394
|
+
try:
|
|
395
|
+
os.link(blob, temp, follow_symlinks=False)
|
|
396
|
+
temp_id = _identity(temp)
|
|
397
|
+
linked_blob = _identity(blob)
|
|
398
|
+
linked_content_id = (
|
|
399
|
+
linked_blob.device, linked_blob.inode, linked_blob.size,
|
|
400
|
+
linked_blob.mtime_ns, linked_blob.allocated_bytes,
|
|
401
|
+
)
|
|
402
|
+
if linked_content_id != blob_content_id or (temp_id.device, temp_id.inode) != (blob_id.device, blob_id.inode):
|
|
403
|
+
raise CheckFailed(f"native cache blob changed during adoption: {blob}")
|
|
404
|
+
if _identity(path) != before:
|
|
405
|
+
raise CheckFailed(f"artifact changed during adoption: {path}")
|
|
406
|
+
os.replace(temp, path)
|
|
407
|
+
directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
408
|
+
try:
|
|
409
|
+
os.fsync(directory_fd)
|
|
410
|
+
finally:
|
|
411
|
+
os.close(directory_fd)
|
|
412
|
+
after = _identity(path)
|
|
413
|
+
final_blob = _identity(blob)
|
|
414
|
+
final_content_id = (
|
|
415
|
+
final_blob.device, final_blob.inode, final_blob.size,
|
|
416
|
+
final_blob.mtime_ns, final_blob.allocated_bytes,
|
|
417
|
+
)
|
|
418
|
+
if final_content_id != blob_content_id or (after.device, after.inode) != (blob_id.device, blob_id.inode):
|
|
419
|
+
raise CheckFailed(f"atomic adoption did not install the native cache inode: {path}")
|
|
420
|
+
return before, after
|
|
421
|
+
except CheckFailed:
|
|
422
|
+
raise
|
|
423
|
+
except OSError as exc:
|
|
424
|
+
raise CheckFailed(f"atomic adoption failed for {path}: {exc}") from exc
|
|
425
|
+
finally:
|
|
426
|
+
try:
|
|
427
|
+
temp.unlink()
|
|
428
|
+
except FileNotFoundError:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def share_certificates(
|
|
433
|
+
cfg: Config,
|
|
434
|
+
other_root: Path,
|
|
435
|
+
*,
|
|
436
|
+
cache_root: Path,
|
|
437
|
+
receipt_path: Path,
|
|
438
|
+
facets: Iterable[str] = DEFAULT_FACETS,
|
|
439
|
+
apply: bool = False,
|
|
440
|
+
) -> dict[str, object]:
|
|
441
|
+
if not os.environ.get("LOCKSTEP_JOB_ID") or os.environ.get("LOCKSTEP_IN_GUARDED_STAGE") != "1":
|
|
442
|
+
raise UsageError("artifact sharing must run inside `lockstep job run` (guarded job identity is missing)")
|
|
443
|
+
other_cfg = validate_roots(cfg, other_root)
|
|
444
|
+
reject_symlink_ancestors(cache_root)
|
|
445
|
+
cache_root = _absolute_without_symlinks(cache_root)
|
|
446
|
+
if cache_root.exists() and not cache_root.is_dir():
|
|
447
|
+
raise CheckFailed(f"artifact cache path is not a directory: {cache_root}")
|
|
448
|
+
facets = tuple(dict.fromkeys(facets))
|
|
449
|
+
cache_probe = cache_root
|
|
450
|
+
while not cache_probe.exists() and cache_probe != cache_probe.parent:
|
|
451
|
+
cache_probe = cache_probe.parent
|
|
452
|
+
if cache_probe.stat().st_dev != cfg.root.stat().st_dev:
|
|
453
|
+
raise CheckFailed("artifact cache and checkouts must be on the same filesystem")
|
|
454
|
+
header = {
|
|
455
|
+
"schema": SCHEMA, "event": "start", "primary": str(cfg.root.resolve()),
|
|
456
|
+
"other": str(other_cfg.root.resolve()), "cache": str(cache_root),
|
|
457
|
+
"toolchain": supported_toolchain(cfg.root)[0], "facets": list(facets), "apply": apply,
|
|
458
|
+
}
|
|
459
|
+
owner_job_id = os.environ.get("LOCKSTEP_JOB_ID")
|
|
460
|
+
target = StorageTarget("artifact-cache", cache_root)
|
|
461
|
+
with exclusive_storage_maintenance(
|
|
462
|
+
cfg, roots=(cfg.root, other_cfg.root), targets=(target,),
|
|
463
|
+
reason="verified sharing of immutable certificate artifacts", owner_job_id=owner_job_id,
|
|
464
|
+
):
|
|
465
|
+
receipt = Receipt(receipt_path.resolve(), header)
|
|
466
|
+
if lean_compilers_running():
|
|
467
|
+
raise CheckFailed("a Lean compiler is running outside this storage reservation")
|
|
468
|
+
candidates, stats = certificate_candidates(cfg, other_cfg, facets)
|
|
469
|
+
adopted_before = receipt.adopted()
|
|
470
|
+
verified = verify_candidates(candidates, receipt)
|
|
471
|
+
mismatch_count = len(candidates) - len(verified)
|
|
472
|
+
adopted_count = 0
|
|
473
|
+
deduplicated_logical_bytes = 0
|
|
474
|
+
eligible_freed_allocated_bytes = 0
|
|
475
|
+
free_before = os.statvfs(cache_probe).f_bavail * os.statvfs(cache_probe).f_frsize
|
|
476
|
+
if apply and verified:
|
|
477
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
478
|
+
reject_symlink_ancestors(cache_root)
|
|
479
|
+
work = receipt_path.resolve().parent / (receipt_path.name + ".work")
|
|
480
|
+
saved = save_native_artifacts(cfg, cache_root, verified, work)
|
|
481
|
+
for item, digest in verified:
|
|
482
|
+
blob = saved[str(item.primary)]
|
|
483
|
+
# A full SHA-256 comparison catches Lake's documented 64-bit-hash collision case.
|
|
484
|
+
if sha256_file(blob) != digest:
|
|
485
|
+
raise CheckFailed(f"native cache hash collision for {item.relative_output}")
|
|
486
|
+
os.chmod(blob, 0o444)
|
|
487
|
+
before_primary, after_primary = atomic_adopt(item.primary, blob, digest)
|
|
488
|
+
before_other, after_other = atomic_adopt(item.other, blob, digest)
|
|
489
|
+
receipt.append({
|
|
490
|
+
"event": "adopted", "module": item.module, "facet": item.facet,
|
|
491
|
+
"relative_output": item.relative_output, "bytes": item.size, "sha256": digest,
|
|
492
|
+
"lake_artifact": str(blob.relative_to(cache_root)),
|
|
493
|
+
"primary_before": asdict(before_primary), "primary_after": asdict(after_primary),
|
|
494
|
+
"other_before": asdict(before_other), "other_after": asdict(after_other),
|
|
495
|
+
})
|
|
496
|
+
adopted_count += 1
|
|
497
|
+
deduplicated_logical_bytes += item.size
|
|
498
|
+
for before, after in ((before_primary, after_primary), (before_other, after_other)):
|
|
499
|
+
if before.inode != after.inode and before.links == 1:
|
|
500
|
+
eligible_freed_allocated_bytes += before.allocated_bytes
|
|
501
|
+
free_after = os.statvfs(cache_probe).f_bavail * os.statvfs(cache_probe).f_frsize
|
|
502
|
+
result = {
|
|
503
|
+
"schema": SCHEMA, "mode": "apply" if apply else "dry-run", "receipt": str(receipt.path),
|
|
504
|
+
"cache": str(cache_root), "facets": list(facets), **stats,
|
|
505
|
+
"verified": len(verified), "mismatches": mismatch_count,
|
|
506
|
+
"adopted": adopted_count,
|
|
507
|
+
"deduplicated_logical_bytes": deduplicated_logical_bytes,
|
|
508
|
+
"eligible_freed_allocated_bytes": eligible_freed_allocated_bytes,
|
|
509
|
+
"observed_free_bytes_before": free_before,
|
|
510
|
+
"observed_free_bytes_after": free_after,
|
|
511
|
+
"observed_free_bytes_delta": free_after - free_before,
|
|
512
|
+
"resumed_adoptions": len(adopted_before),
|
|
513
|
+
}
|
|
514
|
+
receipt.append({"event": "complete", **result})
|
|
515
|
+
return result
|