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.
@@ -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)
@@ -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"]