toolframe-backup 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.
- toolframe_backup/__init__.py +3 -0
- toolframe_backup/archive.py +907 -0
- toolframe_backup/catalog.py +511 -0
- toolframe_backup/cli.py +82 -0
- toolframe_backup/docs/usage.md +88 -0
- toolframe_backup/environments.py +493 -0
- toolframe_backup/page.py +150 -0
- toolframe_backup/plugin.py +220 -0
- toolframe_backup/service.py +105 -0
- toolframe_backup/zipguard.py +178 -0
- toolframe_backup-0.1.0.dist-info/METADATA +148 -0
- toolframe_backup-0.1.0.dist-info/RECORD +16 -0
- toolframe_backup-0.1.0.dist-info/WHEEL +4 -0
- toolframe_backup-0.1.0.dist-info/entry_points.txt +5 -0
- toolframe_backup-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolframe_backup-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Protected exact-lock payloads and final-path environment reconstruction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import unquote, urlsplit
|
|
17
|
+
|
|
18
|
+
import tomlkit
|
|
19
|
+
from packaging.markers import InvalidMarker, Marker
|
|
20
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
21
|
+
from packaging.tags import sys_tags
|
|
22
|
+
from packaging.utils import (
|
|
23
|
+
InvalidWheelFilename,
|
|
24
|
+
canonicalize_name,
|
|
25
|
+
parse_wheel_filename,
|
|
26
|
+
)
|
|
27
|
+
from packaging.version import InvalidVersion, Version
|
|
28
|
+
from tomlkit.exceptions import TOMLKitError
|
|
29
|
+
|
|
30
|
+
from .archive import BackupError
|
|
31
|
+
|
|
32
|
+
LOCK_FIELDS = (
|
|
33
|
+
"candidate_id",
|
|
34
|
+
"purpose",
|
|
35
|
+
"environment_id",
|
|
36
|
+
"sha256",
|
|
37
|
+
"python_version",
|
|
38
|
+
"python_implementation",
|
|
39
|
+
"platform_system",
|
|
40
|
+
"platform_machine",
|
|
41
|
+
"requires_python",
|
|
42
|
+
)
|
|
43
|
+
MAX_ENVIRONMENTS = 100
|
|
44
|
+
_ENV_ID = re.compile(r"[0-9a-f]{64}\Z")
|
|
45
|
+
_PYTHON_VERSION = re.compile(
|
|
46
|
+
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*))?\Z"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fail(code: str, message: str):
|
|
51
|
+
raise BackupError(code, message) from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def capture(inventory, metadata: list[dict]) -> bytes:
|
|
55
|
+
raw = []
|
|
56
|
+
for lock in getattr(inventory, "environment_locks", ()):
|
|
57
|
+
raw.append(
|
|
58
|
+
{
|
|
59
|
+
"candidate_id": lock.candidate_id,
|
|
60
|
+
"purpose": lock.purpose,
|
|
61
|
+
"environment_id": lock.environment_id,
|
|
62
|
+
"lock": lock.lock,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
encoded = json.dumps(
|
|
66
|
+
raw, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
67
|
+
).encode()
|
|
68
|
+
validate(encoded, metadata)
|
|
69
|
+
return encoded
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate(raw: bytes, metadata: list[dict]) -> list[dict]:
|
|
73
|
+
from .archive import SHA256, _unique_json
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
content = json.loads(raw, object_pairs_hook=_unique_json)
|
|
77
|
+
except ValueError, UnicodeError, RecursionError:
|
|
78
|
+
_fail("invalid_environment_lock", "Protected environment locks are invalid.")
|
|
79
|
+
expected = {
|
|
80
|
+
(item["candidate_id"], item["purpose"], item["environment_id"]): item
|
|
81
|
+
for item in metadata
|
|
82
|
+
}
|
|
83
|
+
if not isinstance(content, list) or len(content) != len(expected):
|
|
84
|
+
_fail(
|
|
85
|
+
"invalid_environment_lock",
|
|
86
|
+
"Protected environment locks disagree with metadata.",
|
|
87
|
+
)
|
|
88
|
+
seen, environments = set(), {}
|
|
89
|
+
for item in content:
|
|
90
|
+
if not isinstance(item, dict) or set(item) != {
|
|
91
|
+
"candidate_id",
|
|
92
|
+
"purpose",
|
|
93
|
+
"environment_id",
|
|
94
|
+
"lock",
|
|
95
|
+
}:
|
|
96
|
+
_fail(
|
|
97
|
+
"invalid_environment_lock",
|
|
98
|
+
"Protected environment lock fields are invalid.",
|
|
99
|
+
)
|
|
100
|
+
if not all(isinstance(item[field], str) for field in item):
|
|
101
|
+
_fail(
|
|
102
|
+
"invalid_environment_lock",
|
|
103
|
+
"Protected environment lock values are invalid.",
|
|
104
|
+
)
|
|
105
|
+
key = (item["candidate_id"], item["purpose"], item["environment_id"])
|
|
106
|
+
if key not in expected or key in seen:
|
|
107
|
+
_fail(
|
|
108
|
+
"invalid_environment_lock",
|
|
109
|
+
"Protected environment lock identity is invalid.",
|
|
110
|
+
)
|
|
111
|
+
seen.add(key)
|
|
112
|
+
if not _ENV_ID.fullmatch(item["environment_id"]):
|
|
113
|
+
_fail("invalid_environment_lock", "Environment identity is unsupported.")
|
|
114
|
+
public = expected[key]
|
|
115
|
+
if hashlib.sha256(item["lock"].encode()).hexdigest() != public["sha256"]:
|
|
116
|
+
_fail(
|
|
117
|
+
"checksum_mismatch",
|
|
118
|
+
"Protected environment lock checksum disagrees with metadata.",
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
lock = tomllib.loads(item["lock"])
|
|
122
|
+
except ValueError, RecursionError:
|
|
123
|
+
_fail(
|
|
124
|
+
"invalid_environment_lock",
|
|
125
|
+
"Protected environment lock is invalid TOML.",
|
|
126
|
+
)
|
|
127
|
+
if lock.get("lock-version") != "1.0" or not isinstance(
|
|
128
|
+
lock.get("packages"), list
|
|
129
|
+
):
|
|
130
|
+
_fail(
|
|
131
|
+
"invalid_environment_lock",
|
|
132
|
+
"Only version 1.0 pylock environment locks are supported.",
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
requirement = SpecifierSet(public["requires_python"])
|
|
136
|
+
source_python = Version(public["python_version"])
|
|
137
|
+
except InvalidSpecifier, InvalidVersion, TypeError:
|
|
138
|
+
_fail(
|
|
139
|
+
"invalid_environment_lock", "Python compatibility metadata is invalid."
|
|
140
|
+
)
|
|
141
|
+
if not _PYTHON_VERSION.fullmatch(public["python_version"]):
|
|
142
|
+
_fail(
|
|
143
|
+
"invalid_environment_lock",
|
|
144
|
+
"Recorded Python version must have two or three numeric components.",
|
|
145
|
+
)
|
|
146
|
+
if (
|
|
147
|
+
lock.get("requires-python") != public["requires_python"]
|
|
148
|
+
or len(source_python.release) == 3
|
|
149
|
+
and source_python not in requirement
|
|
150
|
+
):
|
|
151
|
+
_fail(
|
|
152
|
+
"incompatible_environment",
|
|
153
|
+
"Python requirement and original interpreter metadata disagree.",
|
|
154
|
+
)
|
|
155
|
+
_markers(lock)
|
|
156
|
+
for package in lock["packages"]:
|
|
157
|
+
# Both index wheels and direct wheel archives are normal uv pylocks.
|
|
158
|
+
for wheel in _wheel_options(package):
|
|
159
|
+
checksum = wheel.get("hashes", {}).get("sha256")
|
|
160
|
+
if not isinstance(checksum, str) or not SHA256.fullmatch(checksum):
|
|
161
|
+
_fail(
|
|
162
|
+
"invalid_environment_lock",
|
|
163
|
+
"Environment wheels require exact SHA-256 hashes.",
|
|
164
|
+
)
|
|
165
|
+
filename = _wheel_filename(wheel, item["environment_id"])
|
|
166
|
+
try:
|
|
167
|
+
name, version, _, _ = parse_wheel_filename(filename)
|
|
168
|
+
same_package = name == canonicalize_name(
|
|
169
|
+
package["name"]
|
|
170
|
+
) and version == Version(package["version"])
|
|
171
|
+
except InvalidWheelFilename, InvalidVersion, TypeError, KeyError:
|
|
172
|
+
_fail(
|
|
173
|
+
"invalid_environment_lock",
|
|
174
|
+
"A dependency wheel identity is invalid.",
|
|
175
|
+
)
|
|
176
|
+
if not same_package:
|
|
177
|
+
_fail(
|
|
178
|
+
"invalid_environment_lock",
|
|
179
|
+
"A dependency wheel differs from its locked package.",
|
|
180
|
+
)
|
|
181
|
+
_package_marker(package)
|
|
182
|
+
prior = environments.get(item["environment_id"])
|
|
183
|
+
signature = (item["lock"], tuple(public[field] for field in LOCK_FIELDS[4:]))
|
|
184
|
+
if prior is not None and prior != signature:
|
|
185
|
+
_fail(
|
|
186
|
+
"invalid_environment_lock", "Repeated environment identities disagree."
|
|
187
|
+
)
|
|
188
|
+
environments[item["environment_id"]] = signature
|
|
189
|
+
if len(environments) > MAX_ENVIRONMENTS:
|
|
190
|
+
_fail("limit_exceeded", "Recovery supports at most 100 distinct environments.")
|
|
191
|
+
return content
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _markers(lock: dict) -> list[Marker]:
|
|
195
|
+
markers = lock.get("environments", [])
|
|
196
|
+
if not isinstance(markers, list) or any(
|
|
197
|
+
not isinstance(marker, str) for marker in markers
|
|
198
|
+
):
|
|
199
|
+
_fail("invalid_environment_lock", "Environment markers are invalid.")
|
|
200
|
+
try:
|
|
201
|
+
return [Marker(marker) for marker in markers]
|
|
202
|
+
except InvalidMarker:
|
|
203
|
+
_fail("invalid_environment_lock", "Environment markers are invalid.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _wheel_options(package: dict) -> list[dict]:
|
|
207
|
+
if not isinstance(package, dict):
|
|
208
|
+
_fail("invalid_environment_lock", "A dependency record is invalid.")
|
|
209
|
+
if "archive" in package:
|
|
210
|
+
if "wheels" in package or not isinstance(package["archive"], dict):
|
|
211
|
+
_fail(
|
|
212
|
+
"invalid_environment_lock", "A direct wheel archive record is invalid."
|
|
213
|
+
)
|
|
214
|
+
choices = [package["archive"]]
|
|
215
|
+
else:
|
|
216
|
+
choices = package.get("wheels")
|
|
217
|
+
if not isinstance(choices, list) or not choices:
|
|
218
|
+
_fail(
|
|
219
|
+
"unsupported_environment_lock",
|
|
220
|
+
"Recovery requires exact wheel-only environment locks.",
|
|
221
|
+
)
|
|
222
|
+
if any(
|
|
223
|
+
not isinstance(choice, dict) or not isinstance(choice.get("hashes"), dict)
|
|
224
|
+
for choice in choices
|
|
225
|
+
):
|
|
226
|
+
_fail("invalid_environment_lock", "A dependency wheel record is invalid.")
|
|
227
|
+
return choices
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _wheel_filename(wheel: dict, environment_id: str) -> str:
|
|
231
|
+
locators = [key for key in ("path", "url") if key in wheel]
|
|
232
|
+
if len(locators) != 1 or not isinstance(wheel[locators[0]], str):
|
|
233
|
+
_fail("invalid_environment_lock", "A dependency wheel locator is invalid.")
|
|
234
|
+
locator = wheel[locators[0]]
|
|
235
|
+
if not locator or "\\" in locator or any(ord(char) < 32 for char in locator):
|
|
236
|
+
_fail("invalid_environment_lock", "A dependency wheel locator is invalid.")
|
|
237
|
+
if locators[0] == "url":
|
|
238
|
+
try:
|
|
239
|
+
uri = urlsplit(
|
|
240
|
+
locator.replace("${TOOLFRAME_HOME}", "/toolframe-recovery-home")
|
|
241
|
+
)
|
|
242
|
+
except ValueError:
|
|
243
|
+
_fail("invalid_environment_lock", "A dependency wheel locator is invalid.")
|
|
244
|
+
if uri.scheme == "https" and uri.hostname:
|
|
245
|
+
path = unquote(uri.path)
|
|
246
|
+
elif uri.scheme == "file" and uri.netloc in ("", "localhost"):
|
|
247
|
+
path = unquote(uri.path)
|
|
248
|
+
original = (
|
|
249
|
+
path.replace("/toolframe-recovery-home", "${TOOLFRAME_HOME}", 1)
|
|
250
|
+
if "${TOOLFRAME_HOME}" in locator
|
|
251
|
+
else path
|
|
252
|
+
)
|
|
253
|
+
_local_wheel_path(original, environment_id)
|
|
254
|
+
else:
|
|
255
|
+
_fail(
|
|
256
|
+
"unsupported_environment_lock",
|
|
257
|
+
"A dependency wheel location is unsupported.",
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
path = locator
|
|
261
|
+
_local_wheel_path(path, environment_id)
|
|
262
|
+
filename = posixpath.basename(path)
|
|
263
|
+
if not filename.endswith(".whl"):
|
|
264
|
+
_fail("unsupported_environment_lock", "Recovery refuses source archive builds.")
|
|
265
|
+
return filename
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _local_wheel_path(path: str, environment_id: str) -> None:
|
|
269
|
+
root = "/toolframe-recovery-home"
|
|
270
|
+
if path.startswith("${TOOLFRAME_HOME}/"):
|
|
271
|
+
resolved = posixpath.normpath(root + path[len("${TOOLFRAME_HOME}") :])
|
|
272
|
+
elif not path.startswith("/") and "$" not in path and ":" not in path:
|
|
273
|
+
resolved = posixpath.normpath(
|
|
274
|
+
f"{root}/.toolframe/environments/{environment_id}/{path}"
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
_fail(
|
|
278
|
+
"unsupported_environment_lock",
|
|
279
|
+
"A local dependency wheel must remain within the recovered home.",
|
|
280
|
+
)
|
|
281
|
+
if not resolved.startswith(root + "/"):
|
|
282
|
+
_fail(
|
|
283
|
+
"unsupported_environment_lock",
|
|
284
|
+
"A local dependency wheel escapes the recovered home.",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _package_marker(package: dict) -> Marker | None:
|
|
289
|
+
marker = package.get("marker")
|
|
290
|
+
if marker is None:
|
|
291
|
+
return None
|
|
292
|
+
if not isinstance(marker, str):
|
|
293
|
+
_fail("invalid_environment_lock", "Package markers are invalid.")
|
|
294
|
+
try:
|
|
295
|
+
return Marker(marker)
|
|
296
|
+
except InvalidMarker:
|
|
297
|
+
_fail("invalid_environment_lock", "Package markers are invalid.")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _filename_matches_tags(filename: str, tags: set) -> bool:
|
|
301
|
+
try:
|
|
302
|
+
return bool(tags & parse_wheel_filename(filename)[3])
|
|
303
|
+
except InvalidWheelFilename, TypeError, ValueError:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def check_compatibility(
|
|
308
|
+
metadata: list[dict],
|
|
309
|
+
content: list[dict] | None = None,
|
|
310
|
+
*,
|
|
311
|
+
dependency_wheels: list[dict] | tuple = (),
|
|
312
|
+
) -> None:
|
|
313
|
+
actual = Version(platform.python_version())
|
|
314
|
+
for item in metadata:
|
|
315
|
+
recorded = Version(item["python_version"])
|
|
316
|
+
same_version = (
|
|
317
|
+
recorded == actual
|
|
318
|
+
if len(recorded.release) == 3
|
|
319
|
+
else recorded.release == actual.release[:2]
|
|
320
|
+
)
|
|
321
|
+
if (
|
|
322
|
+
not same_version
|
|
323
|
+
or item["python_implementation"].lower()
|
|
324
|
+
!= platform.python_implementation().lower()
|
|
325
|
+
or item["platform_system"] != platform.system()
|
|
326
|
+
or item["platform_machine"] != platform.machine()
|
|
327
|
+
):
|
|
328
|
+
_fail(
|
|
329
|
+
"incompatible_environment",
|
|
330
|
+
"Recovery requires matching recorded Python precision, implementation, operating system and machine architecture.",
|
|
331
|
+
)
|
|
332
|
+
if actual not in SpecifierSet(item["requires_python"]):
|
|
333
|
+
_fail(
|
|
334
|
+
"incompatible_environment",
|
|
335
|
+
"The complete operator Python version does not meet Requires-Python.",
|
|
336
|
+
)
|
|
337
|
+
tags = set(sys_tags())
|
|
338
|
+
grouped = {}
|
|
339
|
+
for wheel in dependency_wheels:
|
|
340
|
+
grouped.setdefault(
|
|
341
|
+
(wheel["environment_id"], wheel["distribution"], wheel["version"]), []
|
|
342
|
+
).append(wheel)
|
|
343
|
+
for choices in grouped.values():
|
|
344
|
+
if not any(
|
|
345
|
+
_filename_matches_tags(wheel["filename"], tags) for wheel in choices
|
|
346
|
+
):
|
|
347
|
+
_fail(
|
|
348
|
+
"incompatible_environment",
|
|
349
|
+
"Recorded dependency wheels do not match the operator interpreter and platform.",
|
|
350
|
+
)
|
|
351
|
+
for protected in content or ():
|
|
352
|
+
lock = tomllib.loads(protected["lock"])
|
|
353
|
+
markers = _markers(lock)
|
|
354
|
+
if markers and not any(marker.evaluate() for marker in markers):
|
|
355
|
+
_fail(
|
|
356
|
+
"incompatible_environment",
|
|
357
|
+
"The operator does not match the recorded environment markers.",
|
|
358
|
+
)
|
|
359
|
+
for package in lock["packages"]:
|
|
360
|
+
marker = _package_marker(package)
|
|
361
|
+
applicable = marker is None or marker.evaluate()
|
|
362
|
+
if applicable and not any(
|
|
363
|
+
_filename_matches_tags(
|
|
364
|
+
_wheel_filename(wheel, protected["environment_id"]), tags
|
|
365
|
+
)
|
|
366
|
+
for wheel in _wheel_options(package)
|
|
367
|
+
):
|
|
368
|
+
_fail(
|
|
369
|
+
"incompatible_environment",
|
|
370
|
+
"A locked package has no compatible operator wheel.",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _run(command: list[str], *, timeout: int = 300) -> None:
|
|
375
|
+
try:
|
|
376
|
+
result = subprocess.run(
|
|
377
|
+
command,
|
|
378
|
+
stdout=subprocess.DEVNULL,
|
|
379
|
+
stderr=subprocess.DEVNULL,
|
|
380
|
+
check=False,
|
|
381
|
+
timeout=timeout,
|
|
382
|
+
)
|
|
383
|
+
except OSError, subprocess.TimeoutExpired:
|
|
384
|
+
_fail(
|
|
385
|
+
"rehydration_failed",
|
|
386
|
+
"Exact environment reconstruction failed; subprocess diagnostics are suppressed to protect credentials.",
|
|
387
|
+
)
|
|
388
|
+
if result.returncode:
|
|
389
|
+
_fail(
|
|
390
|
+
"rehydration_failed",
|
|
391
|
+
"Exact environment reconstruction failed; supply compatible exact-hash wheels and check operator dependency prerequisites.",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _expanded_lock(lock: str, home: Path) -> str:
|
|
396
|
+
"""Expand wheel locators with filesystem or URI encoding as appropriate.
|
|
397
|
+
|
|
398
|
+
The archive retains the original lock. Only the reconstruction copy changes,
|
|
399
|
+
keeping dependency identities, hashes and other protected values unchanged.
|
|
400
|
+
Parsing before substitution also handles TOML literal and escaped strings.
|
|
401
|
+
"""
|
|
402
|
+
token = "${TOOLFRAME_HOME}"
|
|
403
|
+
try:
|
|
404
|
+
document = tomlkit.parse(lock)
|
|
405
|
+
changed = False
|
|
406
|
+
for package in document.get("packages", []):
|
|
407
|
+
choices = (
|
|
408
|
+
[package["archive"]]
|
|
409
|
+
if "archive" in package
|
|
410
|
+
else package.get("wheels", [])
|
|
411
|
+
)
|
|
412
|
+
for wheel in choices:
|
|
413
|
+
if "path" in wheel and token in str(wheel["path"]):
|
|
414
|
+
wheel["path"] = str(wheel["path"]).replace(token, str(home))
|
|
415
|
+
changed = True
|
|
416
|
+
elif "url" in wheel and token in str(wheel["url"]):
|
|
417
|
+
# Percent-encode the home once; retain existing wheel URL
|
|
418
|
+
# escapes so '%' in a real path cannot change their meaning.
|
|
419
|
+
wheel["url"] = str(wheel["url"]).replace(
|
|
420
|
+
token, urlsplit(home.as_uri()).path
|
|
421
|
+
)
|
|
422
|
+
changed = True
|
|
423
|
+
return tomlkit.dumps(document) if changed else lock
|
|
424
|
+
except TOMLKitError, TypeError, KeyError, ValueError:
|
|
425
|
+
_fail("invalid_environment_lock", "Environment lock path expansion failed.")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def rehydrate(
|
|
429
|
+
home: Path,
|
|
430
|
+
content: list[dict],
|
|
431
|
+
*,
|
|
432
|
+
uv_path: str = "uv",
|
|
433
|
+
wheelhouse: Path | None = None,
|
|
434
|
+
offline: bool = False,
|
|
435
|
+
) -> int:
|
|
436
|
+
if not content:
|
|
437
|
+
return 0
|
|
438
|
+
executable = shutil.which(uv_path)
|
|
439
|
+
if executable is None:
|
|
440
|
+
_fail(
|
|
441
|
+
"uv_missing",
|
|
442
|
+
"Exact environment reconstruction requires uv on PATH or --uv-path.",
|
|
443
|
+
)
|
|
444
|
+
flags = ["--offline"] if offline else []
|
|
445
|
+
if wheelhouse is not None:
|
|
446
|
+
wheelhouse = Path(wheelhouse).absolute()
|
|
447
|
+
if not wheelhouse.is_dir():
|
|
448
|
+
_fail(
|
|
449
|
+
"wheelhouse_missing",
|
|
450
|
+
"A local operator-supplied wheelhouse directory is required.",
|
|
451
|
+
)
|
|
452
|
+
flags.extend(["--find-links", str(wheelhouse)])
|
|
453
|
+
unique = {item["environment_id"]: item["lock"] for item in content}
|
|
454
|
+
root = home / ".toolframe" / "environments"
|
|
455
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
456
|
+
for identifier, lock in unique.items():
|
|
457
|
+
target = root / identifier
|
|
458
|
+
if target.exists():
|
|
459
|
+
_fail(
|
|
460
|
+
"environment_exists",
|
|
461
|
+
"Recovery payload unexpectedly contains a runtime environment.",
|
|
462
|
+
)
|
|
463
|
+
# Build at the final absolute path: console-script shebangs remain valid.
|
|
464
|
+
_run(
|
|
465
|
+
[executable, "--no-config", "venv", "--python", sys.executable, str(target)]
|
|
466
|
+
)
|
|
467
|
+
target.chmod(0o700)
|
|
468
|
+
lockfile = target / "pylock.toml"
|
|
469
|
+
fd = os.open(lockfile, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
470
|
+
with os.fdopen(fd, "w") as output:
|
|
471
|
+
output.write(_expanded_lock(lock, home))
|
|
472
|
+
_run(
|
|
473
|
+
[
|
|
474
|
+
executable,
|
|
475
|
+
"--no-config",
|
|
476
|
+
"pip",
|
|
477
|
+
"sync",
|
|
478
|
+
"--python",
|
|
479
|
+
str(target / "bin" / "python"),
|
|
480
|
+
"--require-hashes",
|
|
481
|
+
"--only-binary",
|
|
482
|
+
":all:",
|
|
483
|
+
*flags,
|
|
484
|
+
str(lockfile),
|
|
485
|
+
]
|
|
486
|
+
)
|
|
487
|
+
ready = target / ".ready"
|
|
488
|
+
fd = os.open(ready, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
489
|
+
with os.fdopen(fd, "w") as output:
|
|
490
|
+
output.write(identifier)
|
|
491
|
+
output.flush()
|
|
492
|
+
os.fsync(output.fileno())
|
|
493
|
+
return len(unique)
|
toolframe_backup/page.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Native Dashboard catalog and explicit retention through public SDK services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from toolframe_sdk import PageContext, PluginError
|
|
6
|
+
|
|
7
|
+
from .service import SERVICE_NAME
|
|
8
|
+
|
|
9
|
+
_PLAN_KEY = "backup-retention-preview"
|
|
10
|
+
_NOTICE_KEY = "backup-notice"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rows(backups: list[dict]) -> list[dict]:
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
"Backup ID": item["id"],
|
|
17
|
+
"Created": item["created_at"],
|
|
18
|
+
"Files": item["file_count"],
|
|
19
|
+
"Payload bytes": item["total_bytes"],
|
|
20
|
+
"Last verified": item["last_verified_at"],
|
|
21
|
+
}
|
|
22
|
+
for item in backups
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render(context: PageContext) -> None:
|
|
27
|
+
"""The page never reads an application home or creates a live snapshot."""
|
|
28
|
+
import streamlit as st
|
|
29
|
+
|
|
30
|
+
st.title("Backups")
|
|
31
|
+
service = context.services.require(SERVICE_NAME, version=1)
|
|
32
|
+
try:
|
|
33
|
+
result = service.call("list_backups", {"limit": 100}, timeout=120)
|
|
34
|
+
except PluginError as error:
|
|
35
|
+
st.error(error.message)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
st.caption(f"Local destination: {result['destination']}")
|
|
39
|
+
st.caption(
|
|
40
|
+
f"Retention policy: keep the newest {result['retention_keep']} verified backups for this application. Deletion requires a preview and confirmation."
|
|
41
|
+
)
|
|
42
|
+
st.info(result["guidance"])
|
|
43
|
+
st.code(
|
|
44
|
+
"toolframe-backup create --home APPLICATION_HOME --directory BACKUP_DIRECTORY",
|
|
45
|
+
language="bash",
|
|
46
|
+
)
|
|
47
|
+
st.caption(
|
|
48
|
+
"Replace the placeholders with the stopped application's home and the configured local destination. Archives are unencrypted and contain protected application files. Checksums detect corruption; they are not authentication signatures."
|
|
49
|
+
)
|
|
50
|
+
if result["status"] == "destination_missing":
|
|
51
|
+
st.warning(
|
|
52
|
+
"The configured backup directory does not exist yet. The stopped CLI can create it with protected permissions."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
st.button("Refresh catalog", on_click=lambda: st.session_state.pop(_PLAN_KEY, None))
|
|
56
|
+
verified_column, rejected_column = st.columns(2)
|
|
57
|
+
verified_column.metric("Verified backups", result["verified_count"])
|
|
58
|
+
rejected_column.metric("Rejected candidates", result["rejected_count"])
|
|
59
|
+
notice = st.session_state.pop(_NOTICE_KEY, None)
|
|
60
|
+
if notice:
|
|
61
|
+
st.success(notice)
|
|
62
|
+
if result["backups"]:
|
|
63
|
+
st.dataframe(_rows(result["backups"]), hide_index=True, width="stretch")
|
|
64
|
+
if result["verified_count"] > len(result["backups"]):
|
|
65
|
+
st.caption(
|
|
66
|
+
"Showing the newest 100 verified backups. Retention considers the complete bounded catalog."
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
st.info("No verified backups for this application.")
|
|
70
|
+
if result["rejected"]:
|
|
71
|
+
st.warning(
|
|
72
|
+
"Rejected candidates are preserved. Inspect them with the operator CLI before taking any action."
|
|
73
|
+
)
|
|
74
|
+
st.dataframe(result["rejected"], hide_index=True, width="stretch")
|
|
75
|
+
if not context.can("system.plugins.manage"):
|
|
76
|
+
st.caption("Read-only access")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
st.subheader("Verify an archive")
|
|
80
|
+
st.caption(
|
|
81
|
+
"Archive verification and retention require the Manage plugins operator permission."
|
|
82
|
+
)
|
|
83
|
+
if result["backups"]:
|
|
84
|
+
selected = st.selectbox("Backup ID", [item["id"] for item in result["backups"]])
|
|
85
|
+
if st.button("Verify selected backup"):
|
|
86
|
+
try:
|
|
87
|
+
verified = service.call(
|
|
88
|
+
"verify_backup", {"backup_id": selected}, timeout=120
|
|
89
|
+
)
|
|
90
|
+
except PluginError as error:
|
|
91
|
+
st.error(error.message)
|
|
92
|
+
else:
|
|
93
|
+
st.success(
|
|
94
|
+
f"Backup {verified['id']} passed structure and checksum verification."
|
|
95
|
+
)
|
|
96
|
+
st.caption(f"Archive SHA-256: {verified['archive_sha256']}")
|
|
97
|
+
else:
|
|
98
|
+
st.caption("Create a stopped-application archive before verifying it here.")
|
|
99
|
+
|
|
100
|
+
st.subheader("Explicit retention")
|
|
101
|
+
with st.form("backup-retention-preview-form"):
|
|
102
|
+
max_delete = st.number_input(
|
|
103
|
+
"Maximum backups to delete in this action",
|
|
104
|
+
min_value=1,
|
|
105
|
+
max_value=100,
|
|
106
|
+
value=100,
|
|
107
|
+
step=1,
|
|
108
|
+
)
|
|
109
|
+
if st.form_submit_button("Preview retention"):
|
|
110
|
+
try:
|
|
111
|
+
st.session_state[_PLAN_KEY] = service.call(
|
|
112
|
+
"retention_preview", {"max_delete": max_delete}, timeout=120
|
|
113
|
+
)
|
|
114
|
+
except PluginError as error:
|
|
115
|
+
st.session_state.pop(_PLAN_KEY, None)
|
|
116
|
+
st.error(error.message)
|
|
117
|
+
plan = st.session_state.get(_PLAN_KEY)
|
|
118
|
+
if plan is None:
|
|
119
|
+
return
|
|
120
|
+
st.caption(
|
|
121
|
+
f"Preview expires at {plan['expires_at']}. Catalog changes require a new preview."
|
|
122
|
+
)
|
|
123
|
+
if not plan["delete_count"]:
|
|
124
|
+
st.success("No verified backups exceed the retention policy.")
|
|
125
|
+
return
|
|
126
|
+
st.warning(
|
|
127
|
+
f"This action will permanently delete {plan['delete_count']} verified backups and leave {plan['remaining_count']} verified backups. Unknown files and rejected archives are preserved."
|
|
128
|
+
)
|
|
129
|
+
st.dataframe(_rows(plan["delete"]), hide_index=True, width="stretch")
|
|
130
|
+
confirmed = st.checkbox(
|
|
131
|
+
"I confirm deletion of the previewed backups.",
|
|
132
|
+
key=f"backup-confirm-{plan['plan_token']}",
|
|
133
|
+
)
|
|
134
|
+
if st.button("Delete previewed backups", disabled=not confirmed):
|
|
135
|
+
try:
|
|
136
|
+
result = service.call(
|
|
137
|
+
"apply_retention", {"plan_token": plan["plan_token"]}, timeout=120
|
|
138
|
+
)
|
|
139
|
+
except PluginError as error:
|
|
140
|
+
st.session_state.pop(_PLAN_KEY, None)
|
|
141
|
+
st.error(error.message)
|
|
142
|
+
else:
|
|
143
|
+
st.session_state.pop(_PLAN_KEY, None)
|
|
144
|
+
st.session_state[_NOTICE_KEY] = (
|
|
145
|
+
f"Deleted {result['deleted_count']} verified backups."
|
|
146
|
+
)
|
|
147
|
+
st.rerun()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["render"]
|