cf-bootstrap-instance 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_bootstrap_instance/__init__.py +11 -0
- cf_bootstrap_instance/handoff.py +135 -0
- cf_bootstrap_instance/install.py +119 -0
- cf_bootstrap_instance/layout.py +156 -0
- cf_bootstrap_instance/model.py +322 -0
- cf_bootstrap_instance/process.py +28 -0
- cf_bootstrap_instance/provider.py +671 -0
- cf_bootstrap_instance/recovery.py +380 -0
- cf_bootstrap_instance/semantics/package.trig +36 -0
- cf_bootstrap_instance/semantics/service.trig +93 -0
- cf_bootstrap_instance/service_executor.py +178 -0
- cf_bootstrap_instance/transition_authority.py +136 -0
- cf_bootstrap_instance/uninstall_authority.py +119 -0
- cf_bootstrap_instance/verify.py +358 -0
- cf_bootstrap_instance-0.1.8.dist-info/METADATA +6 -0
- cf_bootstrap_instance-0.1.8.dist-info/RECORD +18 -0
- cf_bootstrap_instance-0.1.8.dist-info/WHEEL +4 -0
- cf_bootstrap_instance-0.1.8.dist-info/entry_points.txt +10 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Bootstrap-owned, pip-free restoration of the accepted minimum service plane."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import configparser
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
from .layout import read_marker
|
|
18
|
+
|
|
19
|
+
SCHEMA = "cf.bootstrap.minimum-service-recovery.v2"
|
|
20
|
+
RESULT_SCHEMA = "cf.bootstrap.minimum-service-recovery.result.v2"
|
|
21
|
+
MANIFEST_SCHEMA = "cf.bootstrap.minimum-service-recovery-manifest.v2"
|
|
22
|
+
MINIMUM_SERVICE_PLANE = frozenset({"cf-runtime", "cf-service-client", "cf-install-services", "cf-service-mcp-server", "packaging"})
|
|
23
|
+
_SAFE_LAUNCHER_NAME = re.compile(r"[A-Za-z0-9._-]+\Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical(value: object) -> bytes:
|
|
27
|
+
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _digest(value: object) -> str:
|
|
31
|
+
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _safe_launcher_name(value: object, label: str) -> str:
|
|
35
|
+
if not isinstance(value, str) or not value or value != value.strip() or not _SAFE_LAUNCHER_NAME.fullmatch(value) or Path(value).name != value or value in {".", ".."}:
|
|
36
|
+
raise ValueError(f"{label} is unsafe")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _launcher_script_name(target_name: str) -> str:
|
|
41
|
+
return _safe_launcher_name(target_name, "launcher target") + (".exe" if os.name == "nt" else "")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _launcher_retained_name(target_name: str) -> str:
|
|
45
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
46
|
+
return f"launcher-{_safe_launcher_name(target_name, 'launcher target')}{suffix}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _link_free(path: Path, label: str) -> None:
|
|
50
|
+
current = path
|
|
51
|
+
while current != Path(current.anchor):
|
|
52
|
+
try:
|
|
53
|
+
value = os.lstat(current)
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
current = current.parent
|
|
56
|
+
continue
|
|
57
|
+
if stat.S_ISLNK(value.st_mode) or (os.name == "nt" and getattr(value, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)):
|
|
58
|
+
raise ValueError(f"{label} contains a link or reparse point")
|
|
59
|
+
current = current.parent
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _regular(path: Path, label: str, directory: bool = False) -> None:
|
|
63
|
+
_link_free(path, label)
|
|
64
|
+
if path.is_symlink() or not (path.is_dir() if directory else path.is_file()):
|
|
65
|
+
raise ValueError(f"{label} is missing or unsafe")
|
|
66
|
+
if os.name == "nt" and getattr(path.stat(), "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400):
|
|
67
|
+
raise ValueError(f"{label} is a reparse point")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load(path: Path, label: str) -> dict[str, object]:
|
|
71
|
+
_regular(path, label)
|
|
72
|
+
try:
|
|
73
|
+
raw = path.read_bytes()
|
|
74
|
+
value = json.loads(raw)
|
|
75
|
+
if raw != _canonical(value):
|
|
76
|
+
raise ValueError(f"{label} is not canonical")
|
|
77
|
+
except (OSError, ValueError, UnicodeDecodeError) as error:
|
|
78
|
+
raise ValueError(f"{label} is invalid") from error
|
|
79
|
+
if not isinstance(value, dict):
|
|
80
|
+
raise ValueError(f"{label} is invalid")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _record(item: object) -> dict[str, object]:
|
|
85
|
+
if not isinstance(item, dict):
|
|
86
|
+
raise ValueError("pinned artifact record is invalid")
|
|
87
|
+
required = {"distribution", "normalized_distribution", "version", "filename", "sha256", "size", "source_kind"}
|
|
88
|
+
if set(item) not in (required, required | {"source_project"}, required | {"source_project", "artifact_kind", "wheel_tags", "executable"}):
|
|
89
|
+
raise ValueError("pinned artifact record is invalid")
|
|
90
|
+
if not all(isinstance(item.get(key), str) and item[key] for key in ("distribution", "normalized_distribution", "version", "filename", "sha256", "source_kind")):
|
|
91
|
+
raise ValueError("pinned artifact record is invalid")
|
|
92
|
+
if type(item["size"]) is not int or item["size"] < 1 or len(item["sha256"]) != 64:
|
|
93
|
+
raise ValueError("pinned artifact record is invalid")
|
|
94
|
+
filename = str(item["filename"])
|
|
95
|
+
if Path(filename).name != filename or not filename.endswith(".whl"):
|
|
96
|
+
raise ValueError("pinned artifact filename is invalid")
|
|
97
|
+
if item["source_kind"] not in {"stonecastle-local", "external-wheelhouse"}:
|
|
98
|
+
raise ValueError("pinned artifact source is invalid")
|
|
99
|
+
return item
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _safe_member(name: str) -> bool:
|
|
103
|
+
path = Path(name)
|
|
104
|
+
return bool(name) and not path.is_absolute() and ".." not in path.parts and "\\" not in name and not name.startswith("/")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _target_layout(target: Path) -> tuple[Path, Path, Path]:
|
|
108
|
+
code = "import json,sysconfig; print(json.dumps([sysconfig.get_path('purelib'),sysconfig.get_path('platlib'),sysconfig.get_path('scripts')]))"
|
|
109
|
+
result = subprocess.run([str(target), "-c", code], check=True, capture_output=True, text=True, timeout=30)
|
|
110
|
+
values = json.loads(result.stdout)
|
|
111
|
+
if not isinstance(values, list) or len(values) != 3 or any(not isinstance(value, str) for value in values):
|
|
112
|
+
raise ValueError("target installation layout is invalid")
|
|
113
|
+
return tuple(Path(value).resolve() for value in values) # type: ignore[return-value]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _within(path: Path, roots: tuple[Path, ...]) -> bool:
|
|
117
|
+
return any(path == root or root in path.parents for root in roots)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _wheel_files(wheel: Path, target: Path) -> dict[Path, bytes]:
|
|
121
|
+
purelib, platlib, scripts = _target_layout(target)
|
|
122
|
+
roots = (purelib, platlib, scripts, target.parent.parent.resolve())
|
|
123
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
124
|
+
members = archive.infolist()
|
|
125
|
+
names = [member.filename for member in members]
|
|
126
|
+
if len(names) != len(set(names)) or any(not _safe_member(name) for name in names):
|
|
127
|
+
raise ValueError("wheel members are unsafe or duplicated")
|
|
128
|
+
dist_info = [name.rsplit("/", 1)[0] for name in names if name.endswith(".dist-info/WHEEL")]
|
|
129
|
+
if len(dist_info) != 1:
|
|
130
|
+
raise ValueError("wheel metadata is invalid")
|
|
131
|
+
metadata_root = dist_info[0]
|
|
132
|
+
metadata_name = metadata_root + "/METADATA"
|
|
133
|
+
record_name = metadata_root + "/RECORD"
|
|
134
|
+
if metadata_name not in names or record_name not in names:
|
|
135
|
+
raise ValueError("wheel metadata is incomplete")
|
|
136
|
+
metadata = archive.read(metadata_name).decode("utf-8")
|
|
137
|
+
fields = dict(line.split(": ", 1) for line in metadata.splitlines() if ": " in line)
|
|
138
|
+
if not fields.get("Name") or not fields.get("Version"):
|
|
139
|
+
raise ValueError("wheel metadata identity is invalid")
|
|
140
|
+
result: dict[Path, bytes] = {}
|
|
141
|
+
for member in members:
|
|
142
|
+
name = member.filename
|
|
143
|
+
if name.endswith("/"):
|
|
144
|
+
continue
|
|
145
|
+
relative = Path(name)
|
|
146
|
+
destination_root = purelib
|
|
147
|
+
if ".data/" in name:
|
|
148
|
+
_, category, tail = name.split("/", 2)
|
|
149
|
+
destination_root = {"purelib": purelib, "platlib": platlib, "scripts": scripts, "data": target.parent.parent.resolve()}.get(category)
|
|
150
|
+
if destination_root is None:
|
|
151
|
+
raise ValueError("wheel data category is invalid")
|
|
152
|
+
relative = Path(tail)
|
|
153
|
+
elif name.startswith(metadata_root + "/"):
|
|
154
|
+
destination_root = purelib
|
|
155
|
+
destination = (destination_root / relative).resolve()
|
|
156
|
+
if not _within(destination, roots) or destination in result:
|
|
157
|
+
raise ValueError("wheel destination escaped or collided")
|
|
158
|
+
result[destination] = archive.read(member)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _launcher_record(item: object) -> dict[str, object]:
|
|
163
|
+
if not isinstance(item, dict):
|
|
164
|
+
raise ValueError("launcher image record is invalid")
|
|
165
|
+
required = {"target_launcher", "retained_filename", "size", "sha256", "mode"}
|
|
166
|
+
if set(item) != required:
|
|
167
|
+
raise ValueError("launcher image record is invalid")
|
|
168
|
+
target = _safe_launcher_name(item["target_launcher"], "launcher target")
|
|
169
|
+
retained = _safe_launcher_name(item["retained_filename"], "retained launcher filename")
|
|
170
|
+
if retained != _launcher_retained_name(target):
|
|
171
|
+
raise ValueError("launcher image record is invalid")
|
|
172
|
+
if type(item["size"]) is not int or item["size"] < 0 or type(item["mode"]) is not int or item["mode"] < 0 or item["mode"] > 0o7777 or not isinstance(item["sha256"], str) or len(item["sha256"]) != 64:
|
|
173
|
+
raise ValueError("launcher image record is invalid")
|
|
174
|
+
return {"target_launcher": target, "retained_filename": retained, "size": item["size"], "sha256": item["sha256"], "mode": item["mode"]}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _launcher_targets(records: list[dict[str, object]]) -> set[str]:
|
|
178
|
+
targets: set[str] = set()
|
|
179
|
+
seen: set[str] = set()
|
|
180
|
+
for record in records:
|
|
181
|
+
wheel = Path(str(record["bundle"]))
|
|
182
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
183
|
+
entry_points = [entry for entry in archive.namelist() if entry.endswith(".dist-info/entry_points.txt")]
|
|
184
|
+
if len(entry_points) > 1:
|
|
185
|
+
raise ValueError("recovery launcher metadata is invalid")
|
|
186
|
+
if not entry_points:
|
|
187
|
+
continue
|
|
188
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
189
|
+
parser.optionxform = str
|
|
190
|
+
parser.read_string(archive.read(entry_points[0]).decode("utf-8"))
|
|
191
|
+
if not parser.has_section("console_scripts"):
|
|
192
|
+
continue
|
|
193
|
+
for target_name, _target_value in parser.items("console_scripts", raw=True):
|
|
194
|
+
target = _safe_launcher_name(target_name, "launcher target")
|
|
195
|
+
key = target.casefold()
|
|
196
|
+
if key in seen:
|
|
197
|
+
raise ValueError("recovery launcher names are duplicated or invalid")
|
|
198
|
+
seen.add(key)
|
|
199
|
+
targets.add(target)
|
|
200
|
+
return targets
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _restore(target: Path, records: list[dict[str, object]], launcher_records: list[dict[str, object]]) -> list[str]:
|
|
204
|
+
purelib, platlib, scripts = _target_layout(target)
|
|
205
|
+
environment = target.parent.parent.resolve()
|
|
206
|
+
staging = Path(tempfile.mkdtemp(prefix="cf-recovery-", dir=str(environment.parent)))
|
|
207
|
+
backup = staging / "backup"
|
|
208
|
+
shutil.copytree(environment, backup, dirs_exist_ok=True)
|
|
209
|
+
try:
|
|
210
|
+
expected_targets = _launcher_targets(records)
|
|
211
|
+
launcher_target_names = {str(record["target_launcher"]).casefold() for record in launcher_records}
|
|
212
|
+
if launcher_target_names != {name.casefold() for name in expected_targets}:
|
|
213
|
+
raise ValueError("recovery launcher metadata is incomplete")
|
|
214
|
+
planned: dict[Path, tuple[bytes, str, int | None]] = {}
|
|
215
|
+
for record in records:
|
|
216
|
+
wheel = Path(str(record["bundle"]))
|
|
217
|
+
_regular(wheel, f"pinned wheel {wheel.name}")
|
|
218
|
+
if wheel.stat().st_size != record["size"] or hashlib.sha256(wheel.read_bytes()).hexdigest() != record["sha256"]:
|
|
219
|
+
raise ValueError(f"pinned wheel verification failed: {wheel.name}")
|
|
220
|
+
for path, data in _wheel_files(wheel, target).items():
|
|
221
|
+
existing = planned.get(path)
|
|
222
|
+
if existing is not None and existing[1] != "wheel":
|
|
223
|
+
raise ValueError("minimum recovery destination collision")
|
|
224
|
+
if existing is not None and existing[0] != data:
|
|
225
|
+
raise ValueError("minimum recovery wheel collision")
|
|
226
|
+
planned[path] = (data, "wheel", None)
|
|
227
|
+
for record in launcher_records:
|
|
228
|
+
launcher = Path(str(record["bundle"]))
|
|
229
|
+
_regular(launcher, f"retained launcher {launcher.name}")
|
|
230
|
+
if launcher.stat().st_size != record["size"] or hashlib.sha256(launcher.read_bytes()).hexdigest() != record["sha256"]:
|
|
231
|
+
raise ValueError(f"retained launcher verification failed: {launcher.name}")
|
|
232
|
+
if str(record["target_launcher"]) not in expected_targets:
|
|
233
|
+
raise ValueError("recovery launcher names are outside the accepted set")
|
|
234
|
+
destination = scripts / _launcher_script_name(str(record["target_launcher"]))
|
|
235
|
+
existing = planned.get(destination)
|
|
236
|
+
if existing is not None:
|
|
237
|
+
raise ValueError("minimum recovery destination collision")
|
|
238
|
+
planned[destination] = (launcher.read_bytes(), "launcher", int(record["mode"]))
|
|
239
|
+
changed: list[str] = []
|
|
240
|
+
for path, (data, kind, mode) in planned.items():
|
|
241
|
+
if path.is_symlink() or (path.exists() and not path.is_file()) or not _within(path, (purelib, platlib, scripts, environment)):
|
|
242
|
+
raise ValueError("target file is unsafe")
|
|
243
|
+
current_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
|
|
244
|
+
if not path.exists() or path.read_bytes() != data:
|
|
245
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
path.write_bytes(data)
|
|
247
|
+
changed.append(str(path))
|
|
248
|
+
if kind == "launcher":
|
|
249
|
+
if current_mode != mode:
|
|
250
|
+
path.chmod(mode)
|
|
251
|
+
if str(path) not in changed:
|
|
252
|
+
changed.append(str(path))
|
|
253
|
+
elif path.parent == scripts and os.name != "nt":
|
|
254
|
+
if current_mode is None or not (current_mode & stat.S_IXUSR):
|
|
255
|
+
path.chmod((current_mode or 0o644) | stat.S_IXUSR)
|
|
256
|
+
if str(path) not in changed:
|
|
257
|
+
changed.append(str(path))
|
|
258
|
+
return changed
|
|
259
|
+
except BaseException:
|
|
260
|
+
if environment.exists():
|
|
261
|
+
shutil.rmtree(environment)
|
|
262
|
+
shutil.copytree(backup, environment, dirs_exist_ok=True)
|
|
263
|
+
raise
|
|
264
|
+
finally:
|
|
265
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def recover(request: object) -> dict[str, object]:
|
|
269
|
+
if not isinstance(request, dict) or set(request) != {"schema", "reason", "profile", "target_python", "bootstrap_plan", "handoff_receipt"} or request["schema"] != SCHEMA:
|
|
270
|
+
raise ValueError("recovery request is invalid")
|
|
271
|
+
|
|
272
|
+
locator = Path(str(request["target_python"]))
|
|
273
|
+
if not locator.is_absolute():
|
|
274
|
+
raise ValueError("recovery target locator is invalid")
|
|
275
|
+
_link_free(locator, "recovery target locator")
|
|
276
|
+
candidates = [parent for parent in locator.parents if os.path.lexists(parent / ".cogniflow-profile-handoff.json")]
|
|
277
|
+
if len(candidates) > 1:
|
|
278
|
+
raise ValueError("persisted accepted handoff could not be located unambiguously")
|
|
279
|
+
root = candidates[0] if candidates else locator.parent.parent.parent
|
|
280
|
+
persisted_receipt = _load(root / ".cogniflow-profile-handoff.json", "persisted accepted handoff")
|
|
281
|
+
record_digest = persisted_receipt.get("record_digest")
|
|
282
|
+
if persisted_receipt.get("schema") != "cf.bootstrap.accepted-handoff.v2" or record_digest != _digest({key: value for key, value in persisted_receipt.items() if key != "record_digest"}):
|
|
283
|
+
raise ValueError("persisted accepted handoff is invalid or lacks recovery authority")
|
|
284
|
+
|
|
285
|
+
marker, _ = read_marker(root / ".cogniflow-instance.json")
|
|
286
|
+
if marker["schema"] != "cf.bootstrap.instance-marker.v2":
|
|
287
|
+
raise ValueError("instance marker does not contain v2 recovery authority")
|
|
288
|
+
manifest_path = root / "state" / "minimum-service-plane" / "recovery-manifest.json"
|
|
289
|
+
manifest = _load(manifest_path, "recovery manifest")
|
|
290
|
+
manifest_raw = _canonical(manifest)
|
|
291
|
+
persisted_plan = manifest.get("bootstrap_plan")
|
|
292
|
+
if not isinstance(persisted_plan, dict) or manifest.get("schema") != MANIFEST_SCHEMA or manifest.get("plan_digest") != _digest(persisted_plan):
|
|
293
|
+
raise ValueError("recovery manifest binding is invalid")
|
|
294
|
+
target = Path(str(persisted_receipt.get("target_python", "")))
|
|
295
|
+
expected_target = root / "environment" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
296
|
+
if target != expected_target or persisted_receipt.get("installation_root") != str(root) or persisted_plan.get("installation_root") != str(root):
|
|
297
|
+
raise ValueError("persisted recovery target binding is invalid")
|
|
298
|
+
if persisted_receipt.get("recovery_manifest_path") != str(manifest_path) or persisted_receipt.get("recovery_manifest_digest") != "sha256:" + hashlib.sha256(manifest_raw).hexdigest():
|
|
299
|
+
raise ValueError("retained recovery authority is stale")
|
|
300
|
+
if (marker.get("installation_root") != str(root) or marker.get("environment_root") != str(root / "environment")
|
|
301
|
+
or marker.get("python_executable") != str(target) or marker.get("plan_digest") != manifest.get("plan_digest")
|
|
302
|
+
or marker.get("instance_id") != manifest.get("instance_id") or marker.get("profile") != persisted_receipt.get("profile")
|
|
303
|
+
or marker.get("profile_digest") != persisted_receipt.get("profile_digest")
|
|
304
|
+
or marker.get("selection_digest") != persisted_receipt.get("bootstrap_selection_digest")
|
|
305
|
+
or marker.get("recovery_manifest_path") != str(manifest_path)
|
|
306
|
+
or marker.get("recovery_manifest_digest") != persisted_receipt.get("recovery_manifest_digest")):
|
|
307
|
+
raise ValueError("instance marker is stale")
|
|
308
|
+
if manifest.get("plan_digest") != persisted_receipt.get("plan_digest") or manifest.get("instance_id") != persisted_receipt.get("instance_id") or manifest.get("artifact_set_digest") != marker.get("artifact_set_digest"):
|
|
309
|
+
raise ValueError("recovery authority digest graph is invalid")
|
|
310
|
+
|
|
311
|
+
if request["handoff_receipt"] != persisted_receipt or request["bootstrap_plan"] != persisted_plan or request["target_python"] != str(target) or request["profile"] != persisted_receipt.get("profile"):
|
|
312
|
+
raise ValueError("caller recovery assertions differ from persisted accepted authority")
|
|
313
|
+
_regular(target, "target interpreter")
|
|
314
|
+
raw_records = manifest.get("artifacts")
|
|
315
|
+
launcher_raw = manifest.get("launcher_images")
|
|
316
|
+
closure = manifest.get("recovery_distributions")
|
|
317
|
+
if not isinstance(raw_records, list) or not isinstance(launcher_raw, list) or not isinstance(closure, list) or any(not isinstance(name, str) for name in closure):
|
|
318
|
+
raise ValueError("recovery manifest artifacts are invalid")
|
|
319
|
+
records = []
|
|
320
|
+
launcher_records = []
|
|
321
|
+
names: set[str] = set()
|
|
322
|
+
launcher_targets: set[str] = set()
|
|
323
|
+
retained_names: set[str] = set()
|
|
324
|
+
bundle = manifest_path.parent
|
|
325
|
+
for raw in raw_records:
|
|
326
|
+
item = _record(raw)
|
|
327
|
+
name = str(item["normalized_distribution"]).replace("_", "-")
|
|
328
|
+
if name in names or name not in closure:
|
|
329
|
+
raise ValueError("recovery manifest contains an unauthorized distribution")
|
|
330
|
+
names.add(name)
|
|
331
|
+
wheel = bundle / str(item["filename"])
|
|
332
|
+
_regular(wheel, "retained recovery wheel")
|
|
333
|
+
if wheel.stat().st_size != item["size"] or hashlib.sha256(wheel.read_bytes()).hexdigest() != item["sha256"]:
|
|
334
|
+
raise ValueError("retained recovery wheel digest is invalid")
|
|
335
|
+
records.append({**item, "bundle": str(wheel)})
|
|
336
|
+
for raw in launcher_raw:
|
|
337
|
+
item = _launcher_record(raw)
|
|
338
|
+
target_name = str(item["target_launcher"])
|
|
339
|
+
retained_name = str(item["retained_filename"])
|
|
340
|
+
if target_name.casefold() in launcher_targets or retained_name.casefold() in retained_names:
|
|
341
|
+
raise ValueError("recovery manifest contains duplicated launcher authority")
|
|
342
|
+
launcher_targets.add(target_name.casefold())
|
|
343
|
+
retained_names.add(retained_name.casefold())
|
|
344
|
+
retained = bundle / retained_name
|
|
345
|
+
_regular(retained, "retained launcher image")
|
|
346
|
+
if retained.stat().st_size != item["size"] or hashlib.sha256(retained.read_bytes()).hexdigest() != item["sha256"] or stat.S_IMODE(retained.lstat().st_mode) != item["mode"]:
|
|
347
|
+
raise ValueError("retained launcher image is invalid")
|
|
348
|
+
launcher_records.append({**item, "bundle": str(retained)})
|
|
349
|
+
if names != set(closure):
|
|
350
|
+
raise ValueError("retained recovery closure is incomplete")
|
|
351
|
+
expected_names = {str(item["filename"]) for item in records} | {str(item["retained_filename"]) for item in launcher_records} | {"recovery-manifest.json"}
|
|
352
|
+
children = list(bundle.iterdir())
|
|
353
|
+
if {child.name for child in children} != expected_names or any(child.is_symlink() or not child.is_file() for child in children):
|
|
354
|
+
raise ValueError("retained recovery bundle contains missing or foreign state")
|
|
355
|
+
before = _inventory(target)
|
|
356
|
+
changed = _restore(target, records, launcher_records)
|
|
357
|
+
after = _inventory(target)
|
|
358
|
+
return {"schema": RESULT_SCHEMA, "status": "recovered", "classified_failure": request["reason"], "restored_files": changed, "inventory_before": before, "inventory_after": after, "target_python": str(target), "handoff": {"status": "fresh-client-required"}}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _inventory(target: Path) -> list[dict[str, str]]:
|
|
362
|
+
code = "import importlib.metadata as m,json; print(json.dumps(sorted([{'name':d.metadata.get('Name'),'version':d.version} for d in m.distributions() if d.metadata.get('Name')], key=lambda x:x['name'].casefold())))"
|
|
363
|
+
result = subprocess.run([str(target), "-c", code], check=True, capture_output=True, text=True, timeout=30)
|
|
364
|
+
value = json.loads(result.stdout)
|
|
365
|
+
if not isinstance(value, list):
|
|
366
|
+
raise ValueError("target inventory is invalid")
|
|
367
|
+
return value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main() -> int:
|
|
371
|
+
try:
|
|
372
|
+
print(json.dumps(recover(json.loads(sys.stdin.read())), sort_keys=True, separators=(",", ":")))
|
|
373
|
+
return 0
|
|
374
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
375
|
+
print(json.dumps({"schema": RESULT_SCHEMA, "status": "failed", "error": str(error)}, separators=(",", ":")))
|
|
376
|
+
return 4
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
@prefix cfpkg: <https://cogniflow.odea-project.org/cf#> .
|
|
2
|
+
@prefix pkg: <urn:cf:pkg:> .
|
|
3
|
+
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
|
4
|
+
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
|
|
5
|
+
|
|
6
|
+
pkg:cf_bootstrap_instance {
|
|
7
|
+
pkg:cf_bootstrap_instance
|
|
8
|
+
a cfpkg:CfPackage ;
|
|
9
|
+
cfpkg:hasPackageManifest [
|
|
10
|
+
a cfpkg:PackageManifest ;
|
|
11
|
+
cfpkg:hasDistributionName [
|
|
12
|
+
a cfpkg:DistributionName ;
|
|
13
|
+
rdf:value "cf-bootstrap-instance"
|
|
14
|
+
] ;
|
|
15
|
+
cfpkg:hasPythonPackageName [
|
|
16
|
+
a cfpkg:PythonPackageName ;
|
|
17
|
+
rdf:value "cf_bootstrap_instance"
|
|
18
|
+
] ;
|
|
19
|
+
cfpkg:hasPackageVersion [
|
|
20
|
+
a cfpkg:PackageVersion ;
|
|
21
|
+
rdf:value "0.1.8"
|
|
22
|
+
] ;
|
|
23
|
+
cfpkg:hasImplementationLanguage [
|
|
24
|
+
a cfpkg:ImplementationLanguage ;
|
|
25
|
+
rdf:value "Python"
|
|
26
|
+
]
|
|
27
|
+
] ;
|
|
28
|
+
cfpkg:hasPackageRole [
|
|
29
|
+
a cfpkg:PackageRole ;
|
|
30
|
+
rdf:value cfpkg:SpecificationPackageRole
|
|
31
|
+
] ;
|
|
32
|
+
skos:prefLabel "cf_bootstrap_instance" ;
|
|
33
|
+
skos:definition "Generated Cogniflow package scaffold." ;
|
|
34
|
+
skos:scopeNote "Package-local semantic source generated from cf-python-package-basic." ;
|
|
35
|
+
skos:example "Consumers can validate this package with the package-template conformance service." .
|
|
36
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
@prefix cfservice: <https://cogniflow.odea-project.org/cf#> .
|
|
2
|
+
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
|
3
|
+
@prefix service: <urn:cf:service:> .
|
|
4
|
+
|
|
5
|
+
<urn:cf:pkg:cf_bootstrap_instance> {
|
|
6
|
+
service:accept_bootstrap_handoff_service a cfservice:Service ;
|
|
7
|
+
cfservice:hasServiceOperation service:accept_bootstrap_handoff .
|
|
8
|
+
service:accept_bootstrap_handoff a cfservice:ServiceOperation ;
|
|
9
|
+
cfservice:hasToolName service:accept_bootstrap_handoff_tool_name ;
|
|
10
|
+
cfservice:hasToolTitle service:accept_bootstrap_handoff_tool_title ;
|
|
11
|
+
cfservice:hasToolDescription service:accept_bootstrap_handoff_tool_description ;
|
|
12
|
+
cfservice:hasOperationCapability service:accept_bootstrap_handoff_capability ;
|
|
13
|
+
cfservice:hasServiceInterface service:accept_bootstrap_handoff_interface ;
|
|
14
|
+
cfservice:hasExecutionAffordance service:accept_bootstrap_handoff_execution .
|
|
15
|
+
service:accept_bootstrap_handoff_tool_name a cfservice:ToolName ; rdf:value "accept_bootstrap_handoff" .
|
|
16
|
+
service:accept_bootstrap_handoff_tool_title a cfservice:ToolTitle ; rdf:value "Accept bootstrap handoff" .
|
|
17
|
+
service:accept_bootstrap_handoff_tool_description a cfservice:ToolDescription ; rdf:value "Accept a verified bootstrap artifact handoff into the target instance." .
|
|
18
|
+
service:accept_bootstrap_handoff_capability a cfservice:OperationCapability ; rdf:value service:BootstrapInstallationHandoff .
|
|
19
|
+
service:accept_bootstrap_handoff_interface a cfservice:ServiceInterface ;
|
|
20
|
+
cfservice:hasServiceInput service:handoff_instance_id, service:handoff_installation_root, service:handoff_plan_digest, service:handoff_artifact_set_digest .
|
|
21
|
+
service:handoff_instance_id a cfservice:ServiceInput ; cfservice:hasArgumentKey service:handoff_instance_id_key ; cfservice:hasValueKind service:handoff_string_kind ; cfservice:hasRequiredFlag service:handoff_required .
|
|
22
|
+
service:handoff_installation_root a cfservice:ServiceInput ; cfservice:hasArgumentKey service:handoff_installation_root_key ; cfservice:hasValueKind service:handoff_string_kind ; cfservice:hasRequiredFlag service:handoff_required .
|
|
23
|
+
service:handoff_plan_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey service:handoff_plan_digest_key ; cfservice:hasValueKind service:handoff_string_kind ; cfservice:hasRequiredFlag service:handoff_required .
|
|
24
|
+
service:handoff_artifact_set_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey service:handoff_artifact_set_digest_key ; cfservice:hasValueKind service:handoff_string_kind ; cfservice:hasRequiredFlag service:handoff_required .
|
|
25
|
+
service:handoff_instance_id_key rdf:value "instance_id" .
|
|
26
|
+
service:handoff_installation_root_key rdf:value "installation_root" .
|
|
27
|
+
service:handoff_plan_digest_key rdf:value "plan_digest" .
|
|
28
|
+
service:handoff_artifact_set_digest_key rdf:value "artifact_set_digest" .
|
|
29
|
+
service:handoff_string_kind rdf:value "json" .
|
|
30
|
+
service:handoff_required rdf:value true .
|
|
31
|
+
service:accept_bootstrap_handoff_execution a cfservice:ExecutionAffordance ;
|
|
32
|
+
cfservice:hasExecutorProtocol service:accept_bootstrap_handoff_protocol ;
|
|
33
|
+
cfservice:hasExecutorModule service:accept_bootstrap_handoff_module ;
|
|
34
|
+
cfservice:hasExecutorFunction service:accept_bootstrap_handoff_function .
|
|
35
|
+
service:accept_bootstrap_handoff_protocol a cfservice:ExecutorProtocol ; rdf:value cfservice:PythonFunctionExecutorV1 .
|
|
36
|
+
service:accept_bootstrap_handoff_module a cfservice:ExecutorModule ; rdf:value "cf_bootstrap_instance.service_executor" .
|
|
37
|
+
service:accept_bootstrap_handoff_function a cfservice:ExecutorFunction ; rdf:value "execute" .
|
|
38
|
+
service:accept_profiled_bootstrap_handoff_service a cfservice:Service ;
|
|
39
|
+
cfservice:hasServiceOperation service:accept_profiled_bootstrap_handoff .
|
|
40
|
+
service:accept_profiled_bootstrap_handoff a cfservice:ServiceOperation ;
|
|
41
|
+
cfservice:hasToolName [ a cfservice:ToolName ; rdf:value "accept_profiled_bootstrap_handoff" ] ;
|
|
42
|
+
cfservice:hasToolTitle [ a cfservice:ToolTitle ; rdf:value "Accept profiled bootstrap handoff" ] ;
|
|
43
|
+
cfservice:hasToolDescription [ a cfservice:ToolDescription ; rdf:value "Accept the verified profiled bootstrap handoff." ] ;
|
|
44
|
+
cfservice:hasOperationCapability [ a cfservice:OperationCapability ; rdf:value service:ProfiledBootstrapInstallationHandoff ] ;
|
|
45
|
+
cfservice:hasServiceInterface [ a cfservice:ServiceInterface ;
|
|
46
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "instance_id" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
47
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "installation_root" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
48
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "cogniflow_home" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
49
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "checkout_path" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
50
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "profile" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
51
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "plan_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
52
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "profile_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
53
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "selection_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
54
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "artifact_set_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
55
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_artifact_root" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
56
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_artifact_set_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
57
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_selection_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
58
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_binding_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
59
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_profile_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
60
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_catalog_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
61
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_repository_source_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ;
|
|
62
|
+
cfservice:hasServiceInput [ a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_index_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] ] ] ;
|
|
63
|
+
cfservice:hasExecutionAffordance [ a cfservice:ExecutionAffordance ;
|
|
64
|
+
cfservice:hasExecutorProtocol [ a cfservice:ExecutorProtocol ; rdf:value cfservice:PythonFunctionExecutorV1 ] ;
|
|
65
|
+
cfservice:hasExecutorModule [ a cfservice:ExecutorModule ; rdf:value "cf_bootstrap_instance.service_executor" ] ;
|
|
66
|
+
cfservice:hasExecutorFunction [ a cfservice:ExecutorFunction ; rdf:value "execute" ] ] .
|
|
67
|
+
service:accept_pypi_profiled_bootstrap_handoff_service a cfservice:Service ;
|
|
68
|
+
cfservice:hasServiceOperation service:accept_pypi_profiled_bootstrap_handoff .
|
|
69
|
+
service:accept_pypi_profiled_bootstrap_handoff a cfservice:ServiceOperation ;
|
|
70
|
+
cfservice:hasToolName [ a cfservice:ToolName ; rdf:value "accept_pypi_profiled_bootstrap_handoff" ] ;
|
|
71
|
+
cfservice:hasToolTitle [ a cfservice:ToolTitle ; rdf:value "Accept public PyPI profiled bootstrap handoff" ] ;
|
|
72
|
+
cfservice:hasToolDescription [ a cfservice:ToolDescription ; rdf:value "Accept the checkout-free public PyPI bootstrap handoff." ] ;
|
|
73
|
+
cfservice:hasOperationCapability [ a cfservice:OperationCapability ; rdf:value service:PublicPyPIProfiledBootstrapInstallationHandoff ] ;
|
|
74
|
+
cfservice:hasServiceInterface service:pypi_profiled_handoff_interface ;
|
|
75
|
+
cfservice:hasExecutionAffordance [ a cfservice:ExecutionAffordance ;
|
|
76
|
+
cfservice:hasExecutorProtocol [ a cfservice:ExecutorProtocol ; rdf:value cfservice:PythonFunctionExecutorV1 ] ;
|
|
77
|
+
cfservice:hasExecutorModule [ a cfservice:ExecutorModule ; rdf:value "cf_bootstrap_instance.service_executor" ] ;
|
|
78
|
+
cfservice:hasExecutorFunction [ a cfservice:ExecutorFunction ; rdf:value "execute" ] ] .
|
|
79
|
+
service:pypi_profiled_handoff_interface a cfservice:ServiceInterface ;
|
|
80
|
+
cfservice:hasServiceInput service:pypi_handoff_instance_id, service:pypi_handoff_installation_root, service:pypi_handoff_cogniflow_home, service:pypi_handoff_artifact_source, service:pypi_handoff_plan_digest, service:pypi_handoff_artifact_set_digest, service:pypi_handoff_profile, service:pypi_handoff_selection_digest, service:pypi_handoff_artifact_root, service:pypi_handoff_post_artifact_set_digest, service:pypi_handoff_post_selection_digest, service:pypi_handoff_binding_digest .
|
|
81
|
+
service:pypi_handoff_instance_id a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "instance_id" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
82
|
+
service:pypi_handoff_installation_root a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "installation_root" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
83
|
+
service:pypi_handoff_cogniflow_home a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "cogniflow_home" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
84
|
+
service:pypi_handoff_artifact_source a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "artifact_source" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
85
|
+
service:pypi_handoff_plan_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "plan_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
86
|
+
service:pypi_handoff_artifact_set_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "artifact_set_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
87
|
+
service:pypi_handoff_profile a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "profile" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
88
|
+
service:pypi_handoff_selection_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "selection_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
89
|
+
service:pypi_handoff_artifact_root a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_artifact_root" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
90
|
+
service:pypi_handoff_post_artifact_set_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_artifact_set_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
91
|
+
service:pypi_handoff_post_selection_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_selection_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
92
|
+
service:pypi_handoff_binding_digest a cfservice:ServiceInput ; cfservice:hasArgumentKey [ rdf:value "post_handoff_binding_digest" ] ; cfservice:hasValueKind [ rdf:value "json" ] ; cfservice:hasRequiredFlag [ rdf:value true ] .
|
|
93
|
+
}
|