PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
storage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""!
5@file storage.py
6@brief Lifecycle-aware archival, offload, verification, and restore workflows.
7
8The storage layer deliberately remains independent of the numerical runtime. It
9packages immutable run/study artifacts, uses rclone as a transport boundary, and
10leaves a small local state marker whenever payload data is pruned.
11"""
12
13import argparse
14import base64
15import contextlib
16import datetime
17import hashlib
18import json
19import os
20import re
21import shutil
22import socket
23import subprocess
24import sys
25import tarfile
26import tempfile
27import uuid
28from pathlib import Path
29
30import yaml
31
32
33STORAGE_CONFIG_FILENAME = ".picurv-storage.yml"
34STORAGE_STATE_FILENAME = ".picurv-storage.json"
35STORAGE_LOCK_FILENAME = ".picurv-storage.lock.json"
36STORAGE_SCHEMA_VERSION = 1
37REMOTE_OBJECTS_DIRECTORY = "objects"
38REMOTE_MANIFEST_FILENAME = "manifest.json"
39REMOTE_COMPLETE_FILENAME = "COMPLETE"
40DEFAULT_PROFILE_NAME = "archive"
41DEFAULT_CHUNK_SIZE_GIB = 8.0
42AUTO_NO_COMPRESSION_BYTES = 256 * 1024 * 1024
43AUTO_MAXIMUM_COMPRESSION_BYTES = 20 * 1024 * 1024 * 1024
44CHECKPOINT_DIRECTORY_PATTERN = re.compile(r"^step_(\d{12})$")
45INCOMPLETE_CHECKPOINT_PATTERN = re.compile(r"^\.step_\d{12}\.incomplete\.")
46ARCHIVE_ID_PATTERN = re.compile(r"^[0-9a-f]{32}$")
47KNOWN_CHECKPOINT_VERSION = 1
48
49
50class StorageError(RuntimeError):
51 """! @brief User-facing storage workflow failure. """
52
53
54def _utc_now() -> str:
55 """!
56 @brief Return a stable UTC timestamp for storage metadata.
57 @return Result produced by this operation.
58 """
59 return datetime.datetime.now(datetime.timezone.utc).isoformat()
60
61
62def _human_bytes(value: int) -> str:
63 """!
64 @brief Format a byte count for concise command output.
65 @param[in] value Value supplied through the `value` argument.
66 @return Result produced by this operation.
67 """
68 size = float(value)
69 for suffix in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
70 if size < 1024.0 or suffix == "PiB":
71 return f"{size:.1f} {suffix}" if suffix != "B" else f"{int(size)} B"
72 size /= 1024.0
73 return f"{int(value)} B"
74
75
76def _sha256_file(path: str) -> str:
77 """!
78 @brief Calculate SHA-256 without loading a potentially large file into memory.
79 @param[in] path Value supplied through the `path` argument.
80 @return Result produced by this operation.
81 """
82 digest = hashlib.sha256()
83 with open(path, "rb") as stream:
84 while True:
85 block = stream.read(8 * 1024 * 1024)
86 if not block:
87 break
88 digest.update(block)
89 return digest.hexdigest()
90
91
92def _atomic_write_json(path: str, payload: dict) -> None:
93 """!
94 @brief Atomically replace a JSON state or manifest file.
95 @param[in] path Value supplied through the `path` argument.
96 @param[in] payload Value supplied through the `payload` argument.
97 """
98 path_abs = os.path.abspath(path)
99 os.makedirs(os.path.dirname(path_abs), exist_ok=True)
100 temporary = f"{path_abs}.tmp.{os.getpid()}"
101 with open(temporary, "w", encoding="utf-8") as stream:
102 json.dump(payload, stream, indent=2, sort_keys=True)
103 stream.write("\n")
104 stream.flush()
105 os.fsync(stream.fileno())
106 os.replace(temporary, path_abs)
107
108
109def _read_json(path: str):
110 """!
111 @brief Read a JSON mapping when present, otherwise return None.
112 @param[in] path Value supplied through the `path` argument.
113 @return Result produced by this operation.
114 """
115 try:
116 with open(path, "r", encoding="utf-8") as stream:
117 payload = json.load(stream)
118 except (OSError, ValueError):
119 return None
120 return payload if isinstance(payload, dict) else None
121
122
123def _find_upwards(start: str, filename: str):
124 """!
125 @brief Find the nearest named file at or above a filesystem anchor.
126 @param[in] start Value supplied through the `start` argument.
127 @param[in] filename Value supplied through the `filename` argument.
128 @return Result produced by this operation.
129 """
130 current = os.path.abspath(start)
131 if os.path.isfile(current):
132 current = os.path.dirname(current)
133 while True:
134 candidate = os.path.join(current, filename)
135 if os.path.isfile(candidate):
136 return candidate
137 parent = os.path.dirname(current)
138 if parent == current:
139 return None
140 current = parent
141
142
143def resolve_storage_config_path(explicit_path: str = None, require: bool = True) -> str:
144 """!
145 @brief Resolve an explicit or nearest workspace storage configuration.
146 @param[in] explicit_path Optional user-selected YAML path.
147 @param[in] require Whether a missing configuration is an error.
148 @return Result produced by this operation.
149 """
150 if explicit_path:
151 result = os.path.abspath(explicit_path)
152 else:
153 result = _find_upwards(os.getcwd(), STORAGE_CONFIG_FILENAME)
154 if result and os.path.isfile(result):
155 return result
156 if require:
157 raise StorageError(
158 "No PICurv storage configuration was found. Run "
159 "'picurv storage setup --remote <rclone-remote:path>' first or pass --storage-config."
160 )
161 return os.path.abspath(explicit_path or os.path.join(os.getcwd(), STORAGE_CONFIG_FILENAME))
162
163
164def load_storage_profile(profile_name: str = None, config_path: str = None) -> dict:
165 """!
166 @brief Load and validate one non-secret rclone storage profile.
167 @param[in] profile_name Value supplied through the `profile_name` argument.
168 @param[in] config_path Value supplied through the `config_path` argument.
169 @return Result produced by this operation.
170 """
171 resolved_config = resolve_storage_config_path(config_path)
172 with open(resolved_config, "r", encoding="utf-8") as stream:
173 payload = yaml.safe_load(stream) or {}
174 profiles = payload.get("profiles")
175 if not isinstance(profiles, dict):
176 raise StorageError(f"Storage config has no 'profiles' mapping: {resolved_config}")
177 selected = profile_name or payload.get("default_profile") or DEFAULT_PROFILE_NAME
178 profile = profiles.get(selected)
179 if not isinstance(profile, dict):
180 raise StorageError(f"Storage profile '{selected}' does not exist in {resolved_config}.")
181 remote = profile.get("remote")
182 if not isinstance(remote, str) or not remote.strip():
183 raise StorageError(f"Storage profile '{selected}' requires a non-empty remote.")
184 result = dict(profile)
185 result["name"] = selected
186 result["remote"] = remote.rstrip("/")
187 result["config_path"] = resolved_config
188 try:
189 chunk_size_gib = float(result.get("chunk_size_gib", DEFAULT_CHUNK_SIZE_GIB))
190 except (TypeError, ValueError) as exc:
191 raise StorageError(f"Storage profile '{selected}' chunk_size_gib must be numeric.") from exc
192 if chunk_size_gib <= 0.0:
193 raise StorageError(f"Storage profile '{selected}' chunk_size_gib must be positive.")
194 result["chunk_size_bytes"] = int(chunk_size_gib * 1024 ** 3)
195 return result
196
197
198def _remote_join(remote: str, *parts: str) -> str:
199 """!
200 @brief Join path components without corrupting rclone remote syntax.
201 @param[in] remote Value supplied through the `remote` argument.
202 @param[in] parts Value supplied through the `parts` argument.
203 @return Result produced by this operation.
204 """
205 clean_parts = [str(part).strip("/") for part in parts if str(part).strip("/")]
206 suffix = "/".join(clean_parts)
207 if not suffix:
208 return remote
209 if remote.endswith(":"):
210 return remote + suffix
211 return remote.rstrip("/") + "/" + suffix
212
213
214def _object_remote(profile: dict, archive_id: str, *parts: str) -> str:
215 """!
216 @brief Return the remote path for one immutable archive object.
217 @param[in] profile Value supplied through the `profile` argument.
218 @param[in] archive_id Value supplied through the `archive_id` argument.
219 @param[in] parts Value supplied through the `parts` argument.
220 @return Result produced by this operation.
221 """
222 return _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY, archive_id, *parts)
223
224
225def _run_rclone(arguments: list, check: bool = True) -> subprocess.CompletedProcess:
226 """!
227 @brief Invoke rclone through the same argv-based subprocess boundary as other PICurv tools.
228 @param[in] arguments Value supplied through the `arguments` argument.
229 @param[in] check Value supplied through the `check` argument.
230 @return Result produced by this operation.
231 """
232 executable = shutil.which("rclone")
233 if not executable:
234 raise StorageError("rclone was not found on PATH. Install/configure rclone before using PICurv storage.")
235 result = subprocess.run(
236 [executable] + [str(item) for item in arguments],
237 text=True,
238 capture_output=True,
239 check=False,
240 )
241 if check and result.returncode != 0:
242 detail = (result.stderr or result.stdout or "unknown rclone error").strip()
243 raise StorageError(f"rclone {' '.join(str(item) for item in arguments[:2])} failed: {detail}")
244 return result
245
246
247def _remote_sha256(remote_path: str) -> str:
248 """!
249 @brief Ask rclone to calculate or retrieve the SHA-256 of one remote object.
250 @param[in] remote_path Value supplied through the `remote_path` argument.
251 @return Result produced by this operation.
252 """
253 result = _run_rclone(["hashsum", "SHA-256", remote_path])
254 for line in result.stdout.splitlines():
255 token = line.strip().split(None, 1)[0] if line.strip() else ""
256 if re.fullmatch(r"[0-9a-fA-F]{64}", token):
257 return token.lower()
258 raise StorageError(f"rclone did not return a SHA-256 for {remote_path}.")
259
260
261def _upload_verified(local_path: str, remote_path: str) -> dict:
262 """!
263 @brief Upload one file, then verify its remote SHA-256.
264 @param[in] local_path Value supplied through the `local_path` argument.
265 @param[in] remote_path Value supplied through the `remote_path` argument.
266 @return Result produced by this operation.
267 """
268 local_digest = _sha256_file(local_path)
269 _run_rclone(["copyto", local_path, remote_path])
270 remote_digest = _remote_sha256(remote_path)
271 if remote_digest != local_digest:
272 raise StorageError(
273 f"Remote checksum mismatch after upload: {remote_path} "
274 f"(local {local_digest}, remote {remote_digest})."
275 )
276 return {"sha256": local_digest, "stored_bytes": os.path.getsize(local_path)}
277
278
279def _read_remote_bytes(remote_path: str) -> bytes:
280 """!
281 @brief Read a small remote catalog object through rclone.
282 @param[in] remote_path Value supplied through the `remote_path` argument.
283 @return Result produced by this operation.
284 """
285 executable = shutil.which("rclone")
286 if not executable:
287 raise StorageError("rclone was not found on PATH.")
288 result = subprocess.run(
289 [executable, "cat", remote_path], capture_output=True, check=False
290 )
291 if result.returncode != 0:
292 detail = (result.stderr or result.stdout or b"unknown rclone error").decode("utf-8", "replace").strip()
293 raise StorageError(f"Unable to read remote object {remote_path}: {detail}")
294 return result.stdout
295
296
297def _load_remote_manifest(profile: dict, archive_id: str, require_complete: bool = True) -> dict:
298 """!
299 @brief Fetch and validate one versioned remote storage manifest.
300 @param[in] profile Value supplied through the `profile` argument.
301 @param[in] archive_id Value supplied through the `archive_id` argument.
302 @param[in] require_complete Value supplied through the `require_complete` argument.
303 @return Result produced by this operation.
304 """
305 if not ARCHIVE_ID_PATTERN.fullmatch(str(archive_id)):
306 raise StorageError(f"Invalid archive ID: {archive_id!r}.")
307 manifest_bytes = _read_remote_bytes(_object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
308 if require_complete:
309 complete = _read_remote_bytes(_object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
310 recorded = complete.decode("ascii", "replace").strip().lower()
311 actual = hashlib.sha256(manifest_bytes).hexdigest()
312 if recorded != actual:
313 raise StorageError(f"Archive {archive_id} has no valid completion marker.")
314 try:
315 manifest = json.loads(manifest_bytes.decode("utf-8"))
316 except (UnicodeDecodeError, ValueError) as exc:
317 raise StorageError(f"Archive {archive_id} has an invalid manifest.") from exc
318 if not isinstance(manifest, dict) or manifest.get("storage_schema_version") != STORAGE_SCHEMA_VERSION:
319 raise StorageError(
320 f"Archive {archive_id} uses unsupported storage schema "
321 f"{manifest.get('storage_schema_version') if isinstance(manifest, dict) else 'unknown'}."
322 )
323 return manifest
324
325
326def _state_path(root_path: str) -> str:
327 """!
328 @brief Return the local storage state marker path for an artifact root.
329 @param[in] root_path Value supplied through the `root_path` argument.
330 @return Result produced by this operation.
331 """
332 return os.path.join(os.path.abspath(root_path), STORAGE_STATE_FILENAME)
333
334
335def read_storage_state(root_path: str):
336 """!
337 @brief Read the nearest applicable storage marker for a run, study, or study member.
338 @param[in] root_path Value supplied through the `root_path` argument.
339 @return Result produced by this operation.
340 """
341 root = Path(os.path.abspath(root_path))
342 state = _read_json(_state_path(str(root)))
343 if state:
344 return state
345 # A whole-study archive owns every numbered member. Let ordinary run
346 # workflows see that parent state without treating unrelated ancestors as
347 # storage owners.
348 if root.parent.name == "cases":
349 return _read_json(_state_path(str(root.parent.parent)))
350 return None
351
352
353def is_artifact_cold(root_path: str) -> bool:
354 """!
355 @brief Return whether a local artifact marker says payload data was pruned.
356 @param[in] root_path Value supplied through the `root_path` argument.
357 @return Result produced by this operation.
358 """
359 state = read_storage_state(root_path)
360 return bool(state and state.get("local_pruned"))
361
362
363def cold_study_members(study_path: str) -> list:
364 """!
365 @brief Return numbered study members whose local payload was pruned.
366 @param[in] study_path Value supplied through the `study_path` argument.
367 @return Result produced by this operation.
368 """
369 cases_dir = Path(os.path.abspath(study_path)) / "cases"
370 if not cases_dir.is_dir():
371 return []
372 return [
373 child.name for child in sorted(cases_dir.iterdir())
374 if child.is_dir() and is_artifact_cold(str(child))
375 ]
376
377
378def require_storage_payload_local(
379 root_path: str,
380 operation: str,
381 checkpoint: int = None,
382 checkpoints=None,
383) -> None:
384 """!
385 @brief Reject a workflow that requires payload currently held in cold storage.
386 @param[in] root_path Run or study-member directory checked by an existing workflow.
387 @param[in] operation Human-readable consuming operation.
388 @param[in] checkpoint Optional single required checkpoint step.
389 @param[in] checkpoints Optional iterable of every required checkpoint step.
390 """
391 state = read_storage_state(root_path)
392 if not state or not state.get("local_pruned"):
393 return
394 required_steps = set()
395 if checkpoint is not None:
396 required_steps.add(int(checkpoint))
397 if checkpoints is not None:
398 required_steps.update(int(step) for step in checkpoints)
399 restored = set(state.get("restored_components") or [])
400 missing_steps = sorted(
401 step for step in required_steps if f"checkpoint:{step}" not in restored
402 )
403 if required_steps and not missing_steps:
404 return
405 archive_id = state.get("archive_id", "<archive-id>")
406 if missing_steps and len(missing_steps) <= 8:
407 suffix = "".join(f" --checkpoint {step}" for step in missing_steps)
408 else:
409 # A full restore is clearer than printing hundreds of repeatable selectors.
410 suffix = ""
411 raise StorageError(
412 f"{operation} requires payload archived from {os.path.abspath(root_path)}. Restore it first with:\n"
413 f" picurv storage restore --archive-id {archive_id}{suffix}"
414 )
415
416
417def storage_state_summary(root_path: str) -> dict:
418 """!
419 @brief Return a compact storage status for summarize and status commands.
420 @param[in] root_path Value supplied through the `root_path` argument.
421 @return Result produced by this operation.
422 """
423 state = read_storage_state(root_path)
424 if not state:
425 return {"state": "LOCAL", "archive_id": None, "label": None}
426 if state.get("local_pruned"):
427 status = "PARTIAL" if state.get("restored_components") else "COLD"
428 else:
429 status = "PROTECTED"
430 return {
431 "state": status,
432 "archive_id": state.get("archive_id"),
433 "label": state.get("label"),
434 "profile": state.get("profile"),
435 "remote": state.get("remote"),
436 }
437
438
439def _validate_case_id(case_id: str) -> str:
440 """!
441 @brief Validate a canonical numbered study-member identifier.
442 @param[in] case_id Value supplied through the `case_id` argument.
443 @return Result produced by this operation.
444 """
445 if not re.fullmatch(r"case_\d+", str(case_id or "")):
446 raise StorageError(f"Invalid study case ID {case_id!r}; expected a value such as case_0003.")
447 return str(case_id)
448
449
450def resolve_local_storage_targets(run_dir: str = None, study_dir: str = None, case_ids=None) -> list:
451 """!
452 @brief Resolve explicit run/study selectors into concrete artifact descriptions.
453 @param[in] run_dir Value supplied through the `run_dir` argument.
454 @param[in] study_dir Value supplied through the `study_dir` argument.
455 @param[in] case_ids Value supplied through the `case_ids` argument.
456 @return Result produced by this operation.
457 """
458 if bool(run_dir) == bool(study_dir):
459 raise StorageError("Select exactly one of --run-dir or --study-dir.")
460 if run_dir:
461 if case_ids:
462 raise StorageError("--case-id is valid only with --study-dir.")
463 root = os.path.abspath(run_dir)
464 if not os.path.isdir(root):
465 raise StorageError(f"Run directory not found: {root}")
466 if not os.path.isdir(os.path.join(root, "config")):
467 raise StorageError(f"Directory does not look like a PICurv run (missing config/): {root}")
468 return [{
469 "artifact_type": "run",
470 "root_path": root,
471 "original_path": root,
472 "run_id": os.path.basename(root),
473 "study_id": None,
474 "case_id": None,
475 }]
476
477 study_root = os.path.abspath(study_dir)
478 if not os.path.isdir(study_root):
479 raise StorageError(f"Study directory not found: {study_root}")
480 if not os.path.isdir(os.path.join(study_root, "cases")):
481 raise StorageError(f"Directory does not look like a PICurv study (missing cases/): {study_root}")
482 if case_ids:
483 targets = []
484 for raw_case_id in case_ids:
485 case_id = _validate_case_id(raw_case_id)
486 case_root = os.path.join(study_root, "cases", case_id)
487 if not os.path.isdir(case_root):
488 raise StorageError(f"Study member not found: {case_root}")
489 targets.append({
490 "artifact_type": "study-case",
491 "root_path": case_root,
492 "original_path": case_root,
493 "run_id": case_id,
494 "study_id": os.path.basename(study_root),
495 "case_id": case_id,
496 "study_path": study_root,
497 })
498 return targets
499 return [{
500 "artifact_type": "study",
501 "root_path": study_root,
502 "original_path": study_root,
503 "run_id": None,
504 "study_id": os.path.basename(study_root),
505 "case_id": None,
506 }]
507
508
509def _path_is_within(root: str, candidate: str) -> bool:
510 """!
511 @brief Return whether an absolute candidate remains within a root directory.
512 @param[in] root Value supplied through the `root` argument.
513 @param[in] candidate Value supplied through the `candidate` argument.
514 @return Result produced by this operation.
515 """
516 try:
517 return os.path.commonpath([os.path.abspath(root), os.path.abspath(candidate)]) == os.path.abspath(root)
518 except ValueError:
519 return False
520
521
522def _resolve_configured_path(root: str, value: str) -> str:
523 """!
524 @brief Resolve runtime-directory syntax using the run directory as working directory.
525 @param[in] root Value supplied through the `root` argument.
526 @param[in] value Value supplied through the `value` argument.
527 @return Result produced by this operation.
528 """
529 return os.path.abspath(value if os.path.isabs(value) else os.path.join(root, value))
530
531
532def _artifact_runtime_roots(root: str) -> list:
533 """!
534 @brief Return run-like roots contained by a standalone run or whole study.
535 @param[in] root Value supplied through the `root` argument.
536 @return Result produced by this operation.
537 """
538 root_path = Path(os.path.abspath(root))
539 case_root = root_path / "cases"
540 if case_root.is_dir():
541 return [
542 str(path) for path in sorted(case_root.glob("case_*"))
543 if path.is_dir()
544 ]
545 return [str(root_path)]
546
547
548def _discover_external_paths(root: str) -> list:
549 """!
550 @brief Report configured data paths that escape the archived directory boundary.
551 @param[in] root Value supplied through the `root` argument.
552 @return Result produced by this operation.
553 """
554 archive_root = os.path.abspath(root)
555 external = []
556 for runtime_root in _artifact_runtime_roots(root):
557 config_dir = os.path.join(runtime_root, "config")
558 source_prefix = os.path.relpath(runtime_root, archive_root).replace(os.sep, "/")
559 source_prefix = "" if source_prefix == "." else source_prefix + ":"
560 monitor_path = os.path.join(config_dir, "monitor.yml")
561 if os.path.isfile(monitor_path):
562 try:
563 with open(monitor_path, "r", encoding="utf-8") as stream:
564 monitor = yaml.safe_load(stream) or {}
565 directories = ((monitor.get("io") or {}).get("directories") or {})
566 if isinstance(directories, dict):
567 for key, value in directories.items():
568 if isinstance(value, str) and value.strip():
569 resolved = _resolve_configured_path(runtime_root, value.strip())
570 if not _path_is_within(archive_root, resolved):
571 external.append({
572 "source": f"{source_prefix}monitor.io.directories.{key}",
573 "path": resolved,
574 })
575 except (OSError, ValueError, TypeError):
576 pass
577 post_path = os.path.join(config_dir, "post.yml")
578 if not os.path.isfile(post_path):
579 continue
580 try:
581 with open(post_path, "r", encoding="utf-8") as stream:
582 post = yaml.safe_load(stream) or {}
583 values = [
584 ("post.io.output_directory", (post.get("io") or {}).get("output_directory")),
585 ("post.source_data.directory", (post.get("source_data") or {}).get("directory")),
586 ]
587 for source, value in values:
588 if not isinstance(value, str) or not value.strip() or value == "<solver_output_dir>":
589 continue
590 resolved = _resolve_configured_path(runtime_root, value.strip())
591 if not _path_is_within(archive_root, resolved):
592 external.append({"source": f"{source_prefix}{source}", "path": resolved})
593 except (OSError, ValueError, TypeError):
594 pass
595 return external
596
597
598def _discover_dependencies(root: str) -> list:
599 """!
600 @brief Discover absolute restart/source paths embedded in generated controls.
601 @param[in] root Value supplied through the `root` argument.
602 @return Result produced by this operation.
603 """
604 dependencies = []
605 archive_root = os.path.abspath(root)
606 controls = []
607 for runtime_root in _artifact_runtime_roots(root):
608 controls.extend(sorted(Path(runtime_root).glob("config/*.control")))
609 for control in controls:
610 try:
611 lines = control.read_text(encoding="utf-8", errors="replace").splitlines()
612 except OSError:
613 continue
614 for line in lines:
615 stripped = line.strip()
616 if not stripped.startswith("-restart_dir "):
617 continue
618 try:
619 tokens = __import__("shlex").split(stripped)
620 except ValueError:
621 continue
622 if len(tokens) >= 2 and os.path.isabs(tokens[1]) and not _path_is_within(archive_root, tokens[1]):
623 dependencies.append({"kind": "restart", "path": os.path.abspath(tokens[1])})
624 return dependencies
625
626
627def _walk_archive_entries(root: str) -> list:
628 """!
629 @brief Enumerate archive entries without following symlinks.
630 @param[in] root Value supplied through the `root` argument.
631 @return Result produced by this operation.
632 """
633 root_abs = os.path.abspath(root)
634 entries = []
635
636 def visit(directory: str):
637 """!
638 @brief Recursively inventory directory entries without following symlinks.
639 @param[in] directory Value supplied through the `directory` argument.
640 """
641 try:
642 children = sorted(os.scandir(directory), key=lambda item: item.name)
643 except OSError as exc:
644 raise StorageError(f"Unable to inventory {directory}: {exc}") from exc
645 for child in children:
646 if child.name in {STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME}:
647 continue
648 rel = os.path.relpath(child.path, root_abs).replace(os.sep, "/")
649 try:
650 stat_result = child.stat(follow_symlinks=False)
651 except OSError as exc:
652 raise StorageError(f"Unable to stat {child.path}: {exc}") from exc
653 if child.is_symlink():
654 entry_type = "symlink"
655 size = 0
656 elif child.is_dir(follow_symlinks=False):
657 entry_type = "directory"
658 size = 0
659 elif child.is_file(follow_symlinks=False):
660 entry_type = "file"
661 size = int(stat_result.st_size)
662 else:
663 raise StorageError(f"Unsupported filesystem entry in artifact: {child.path}")
664 entries.append({
665 "path": rel,
666 "type": entry_type,
667 "size": size,
668 "mode": int(stat_result.st_mode & 0o7777),
669 "mtime_ns": int(stat_result.st_mtime_ns),
670 })
671 if entry_type == "directory":
672 visit(child.path)
673
674 visit(root_abs)
675 return entries
676
677
678def _checkpoint_component(relative_path: str):
679 """!
680 @brief Return a checkpoint component token for a path inside a committed step bundle.
681 @param[in] relative_path Value supplied through the `relative_path` argument.
682 @return Result produced by this operation.
683 """
684 parts = relative_path.split("/")
685 for index, part in enumerate(parts):
686 match = CHECKPOINT_DIRECTORY_PATTERN.fullmatch(part)
687 if match and index > 0 and parts[index - 1] == "checkpoints":
688 return f"checkpoint:{int(match.group(1))}"
689 return None
690
691
692def _classify_component(relative_path: str) -> str:
693 """!
694 @brief Classify one artifact path for packaging and local retention.
695 @param[in] relative_path Value supplied through the `relative_path` argument.
696 @return Result produced by this operation.
697 """
698 checkpoint = _checkpoint_component(relative_path)
699 if checkpoint:
700 return checkpoint
701 parts = relative_path.split("/")
702 first = parts[0]
703 base = os.path.basename(relative_path)
704 if len(parts) >= 3 and parts[0] == "cases" and re.fullmatch(r"case_\d+", parts[1]):
705 first = parts[2]
706 if first in {"config", "scheduler"} or base in {
707 "manifest.json", "study_manifest.json", "study.yml", "cluster.yml"
708 }:
709 return "metadata"
710 if first == "results":
711 return "results"
712 if first == "logs":
713 return "logs"
714 return "data"
715
716
717def _checkpoint_steps(entries: list) -> list:
718 """!
719 @brief Return committed checkpoint steps represented in an inventory.
720 @param[in] entries Value supplied through the `entries` argument.
721 @return Result produced by this operation.
722 """
723 candidates = {}
724 for entry in entries:
725 component = _checkpoint_component(entry["path"])
726 if component:
727 step = int(component.split(":", 1)[1])
728 candidates.setdefault(step, set()).add(os.path.basename(entry["path"]))
729 return sorted(step for step, names in candidates.items() if {"checkpoint.meta", "COMMITTED"} <= names)
730
731
732def _find_incomplete_checkpoints(entries: list) -> list:
733 """!
734 @brief Return incomplete checkpoint paths that make archival unsafe.
735 @param[in] entries Value supplied through the `entries` argument.
736 @return Result produced by this operation.
737 """
738 return [
739 entry["path"] for entry in entries
740 if any(INCOMPLETE_CHECKPOINT_PATTERN.match(part) for part in entry["path"].split("/"))
741 ]
742
743
744def _lock_owner_active(metadata_path: str) -> bool:
745 """!
746 @brief Conservatively determine whether a solver/post/storage owner marker is active.
747 @param[in] metadata_path Value supplied through the `metadata_path` argument.
748 @return Result produced by this operation.
749 """
750 owner = _read_json(metadata_path)
751 if not owner:
752 return True
753 host = owner.get("host")
754 pid = owner.get("pid")
755 if host and host != socket.gethostname():
756 return True
757 try:
758 pid = int(pid)
759 except (TypeError, ValueError):
760 return True
761 try:
762 os.kill(pid, 0)
763 except ProcessLookupError:
764 return False
765 except (PermissionError, OSError):
766 return True
767 return True
768
769
770def _collect_job_ids(payload) -> set:
771 """!
772 @brief Recursively collect submitted Slurm job IDs from scheduler metadata.
773 @param[in] payload Value supplied through the `payload` argument.
774 @return Result produced by this operation.
775 """
776 result = set()
777 if isinstance(payload, dict):
778 if payload.get("submitted") and payload.get("job_id") is not None:
779 result.add(str(payload["job_id"]).strip())
780 for value in payload.values():
781 result.update(_collect_job_ids(value))
782 elif isinstance(payload, list):
783 for value in payload:
784 result.update(_collect_job_ids(value))
785 return {item for item in result if item}
786
787
788def _slurm_activity(root: str) -> dict:
789 """!
790 @brief Query live Slurm state for every job recorded below an artifact scheduler directory.
791 @param[in] root Value supplied through the `root` argument.
792 @return Result produced by this operation.
793 """
794 job_ids = set()
795 scheduler_dirs = [Path(root) / "scheduler"]
796 if (Path(root) / "cases").is_dir():
797 scheduler_dirs.extend((Path(root) / "cases").glob("*/scheduler"))
798 for scheduler in scheduler_dirs:
799 if not scheduler.is_dir():
800 continue
801 for path in scheduler.glob("submission*.json"):
802 job_ids.update(_collect_job_ids(_read_json(str(path))))
803 if not job_ids:
804 return {"job_ids": [], "active": [], "unknown": False}
805 squeue = shutil.which("squeue")
806 if not squeue:
807 return {"job_ids": sorted(job_ids), "active": [], "unknown": True}
808 result = subprocess.run(
809 [squeue, "-h", "-j", ",".join(sorted(job_ids)), "-o", "%i|%T"],
810 text=True,
811 capture_output=True,
812 check=False,
813 )
814 if result.returncode != 0:
815 return {"job_ids": sorted(job_ids), "active": [], "unknown": True}
816 active = []
817 for line in result.stdout.splitlines():
818 if not line.strip():
819 continue
820 job_id, _, state = line.partition("|")
821 active.append({"job_id": job_id.strip(), "state": state.strip() or "UNKNOWN"})
822 return {"job_ids": sorted(job_ids), "active": active, "unknown": False}
823
824
825def inspect_artifact(target: dict, query_scheduler: bool = True) -> dict:
826 """!
827 @brief Build a read-only inventory and lifecycle assessment for one artifact.
828 @param[in] target Value supplied through the `target` argument.
829 @param[in] query_scheduler Value supplied through the `query_scheduler` argument.
830 @return Result produced by this operation.
831 """
832 root = target["root_path"]
833 entries = _walk_archive_entries(root)
834 for entry in entries:
835 entry["component"] = _classify_component(entry["path"])
836 lock_paths = []
837 for name in ("post.lock.json", "solver.lock.json"):
838 for candidate in Path(root).glob(f"**/scheduler/{name}"):
839 if _lock_owner_active(str(candidate)):
840 lock_paths.append(os.path.relpath(candidate, root))
841 slurm = _slurm_activity(root) if query_scheduler else {"job_ids": [], "active": [], "unknown": False}
842 state = storage_state_summary(root)
843 return {
844 "target": dict(target),
845 "entries": entries,
846 "file_count": sum(entry["type"] in {"file", "symlink"} for entry in entries),
847 "total_bytes": sum(entry["size"] for entry in entries),
848 "checkpoint_steps": _checkpoint_steps(entries),
849 "incomplete_checkpoints": _find_incomplete_checkpoints(entries),
850 "active_locks": sorted(lock_paths),
851 "slurm": slurm,
852 "external_paths": _discover_external_paths(root),
853 "dependencies": _discover_dependencies(root),
854 "storage": state,
855 }
856
857
858def _assert_archive_safe(inventory: dict) -> None:
859 """!
860 @brief Refuse to package a changing or scheduler-ambiguous artifact.
861 @param[in] inventory Value supplied through the `inventory` argument.
862 """
863 problems = []
864 if inventory["incomplete_checkpoints"]:
865 problems.append("incomplete checkpoint(s): " + ", ".join(inventory["incomplete_checkpoints"][:3]))
866 if inventory["active_locks"]:
867 problems.append("active runtime lock(s): " + ", ".join(inventory["active_locks"]))
868 if inventory["slurm"]["active"]:
869 problems.append(
870 "active Slurm job(s): " + ", ".join(
871 f"{item['job_id']} ({item['state']})" for item in inventory["slurm"]["active"]
872 )
873 )
874 if inventory["slurm"]["unknown"]:
875 problems.append(
876 "recorded Slurm job IDs could not be checked because squeue is unavailable or failed"
877 )
878 if problems:
879 raise StorageError("Artifact is not safe to archive/offload: " + "; ".join(problems) + ".")
880
881
882@contextlib.contextmanager
883def storage_operation_lock(root_path: str, operation: str):
884 """!
885 @brief Hold an exclusive local storage-operation marker for one artifact.
886 @param[in] root_path Value supplied through the `root_path` argument.
887 @param[in] operation Value supplied through the `operation` argument.
888 """
889 root = os.path.abspath(root_path)
890 lock_path = os.path.join(root, STORAGE_LOCK_FILENAME)
891 if os.path.exists(lock_path):
892 if _lock_owner_active(lock_path):
893 raise StorageError(f"Another storage operation owns {lock_path}.")
894 try:
895 os.remove(lock_path)
896 except OSError as exc:
897 raise StorageError(f"Unable to remove stale storage lock {lock_path}: {exc}") from exc
898 payload = {"operation": operation, "pid": os.getpid(), "host": socket.gethostname(), "started_at": _utc_now()}
899 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
900 try:
901 descriptor = os.open(lock_path, flags, 0o600)
902 with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
903 json.dump(payload, stream, indent=2, sort_keys=True)
904 stream.write("\n")
905 yield
906 finally:
907 try:
908 os.remove(lock_path)
909 except FileNotFoundError:
910 pass
911
912
913@contextlib.contextmanager
914def runtime_stage_lock(root_path: str, stage: str):
915 """!
916 @brief Mark a locally executed solver/post stage as active for storage safety.
917 @param[in] root_path Run directory used as the runtime working directory.
918 @param[in] stage Runtime stage label; storage currently uses this for solver execution.
919 """
920 scheduler = os.path.join(os.path.abspath(root_path), "scheduler")
921 os.makedirs(scheduler, exist_ok=True)
922 lock_path = os.path.join(scheduler, f"{stage}.lock.json")
923 if os.path.exists(lock_path):
924 if _lock_owner_active(lock_path):
925 raise StorageError(f"A {stage} runtime stage already owns {lock_path}.")
926 os.remove(lock_path)
927 payload = {"stage": stage, "pid": os.getpid(), "host": socket.gethostname(), "started_at": _utc_now()}
928 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
929 descriptor = os.open(lock_path, flags, 0o600)
930 with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
931 json.dump(payload, stream, indent=2, sort_keys=True)
932 stream.write("\n")
933 try:
934 yield
935 finally:
936 try:
937 os.remove(lock_path)
938 except FileNotFoundError:
939 pass
940
941
942def _select_compression(requested: str, total_bytes: int, profile: dict) -> str:
943 """!
944 @brief Resolve automatic or configured compression policy.
945 @param[in] requested Value supplied through the `requested` argument.
946 @param[in] total_bytes Value supplied through the `total_bytes` argument.
947 @param[in] profile Value supplied through the `profile` argument.
948 @return Result produced by this operation.
949 """
950 selected = requested or profile.get("compression", "auto")
951 selected = str(selected).strip().lower()
952 if selected not in {"auto", "none", "fast", "balanced", "maximum"}:
953 raise StorageError("Compression must be one of: auto, none, fast, balanced, maximum.")
954 if selected != "auto":
955 return selected
956 if total_bytes < AUTO_NO_COMPRESSION_BYTES:
957 return "none"
958 if total_bytes >= AUTO_MAXIMUM_COMPRESSION_BYTES:
959 return "maximum"
960 return "balanced"
961
962
963def _chunk_extension(compression: str) -> str:
964 """!
965 @brief Return the archive suffix for a compression policy.
966 @param[in] compression Value supplied through the `compression` argument.
967 @return Result produced by this operation.
968 """
969 return {"none": ".tar", "fast": ".tar.gz", "balanced": ".tar.gz", "maximum": ".tar.xz"}[compression]
970
971
972def _build_chunk_specs(inventory: dict, chunk_size_bytes: int) -> list:
973 """!
974 @brief Group archive entries into independently transferable component chunks.
975 @param[in] inventory Value supplied through the `inventory` argument.
976 @param[in] chunk_size_bytes Value supplied through the `chunk_size_bytes` argument.
977 @return Result produced by this operation.
978 """
979 groups = {}
980 directories = []
981 for entry in inventory["entries"]:
982 if entry["type"] == "directory":
983 directories.append(entry["path"])
984 continue
985 groups.setdefault(entry["component"], []).append(entry)
986 specs = []
987 if directories:
988 specs.append({"component": "metadata", "entries": directories, "uncompressed_bytes": 0})
989 component_order = sorted(groups, key=lambda name: (not name.startswith("checkpoint:"), name))
990 for component in component_order:
991 current = []
992 current_bytes = 0
993 for entry in groups[component]:
994 entry_size = max(1, int(entry["size"]))
995 if current and current_bytes + entry_size > chunk_size_bytes:
996 specs.append({"component": component, "entries": current, "uncompressed_bytes": current_bytes})
997 current = []
998 current_bytes = 0
999 current.append(entry["path"])
1000 current_bytes += int(entry["size"])
1001 if current:
1002 specs.append({"component": component, "entries": current, "uncompressed_bytes": current_bytes})
1003 return specs
1004
1005
1006def _safe_component_name(component: str) -> str:
1007 """!
1008 @brief Convert a component token into a portable archive filename fragment.
1009 @param[in] component Value supplied through the `component` argument.
1010 @return Result produced by this operation.
1011 """
1012 return re.sub(r"[^A-Za-z0-9_.-]+", "-", component).strip("-") or "data"
1013
1014
1015def _write_tar_chunk(root: str, spec: dict, destination: str, compression: str) -> None:
1016 """!
1017 @brief Package explicitly inventoried entries without following symlinks.
1018 @param[in] root Value supplied through the `root` argument.
1019 @param[in] spec Value supplied through the `spec` argument.
1020 @param[in] destination Value supplied through the `destination` argument.
1021 @param[in] compression Value supplied through the `compression` argument.
1022 """
1023 kwargs = {}
1024 if compression == "none":
1025 mode = "w"
1026 elif compression == "fast":
1027 mode = "w:gz"
1028 kwargs["compresslevel"] = 1
1029 elif compression == "balanced":
1030 mode = "w:gz"
1031 kwargs["compresslevel"] = 6
1032 else:
1033 mode = "w:xz"
1034 kwargs["preset"] = 9
1035 with tarfile.open(destination, mode, dereference=False, **kwargs) as archive:
1036 for relative in spec["entries"]:
1037 source = os.path.join(root, *relative.split("/"))
1038 if not os.path.lexists(source):
1039 raise StorageError(f"Artifact changed during packaging; entry disappeared: {source}")
1040 archive.add(source, arcname=relative, recursive=False)
1041
1042
1043def _capture_study_context(target: dict) -> list:
1044 """!
1045 @brief Embed small study control-plane files with an individually archived member.
1046 @param[in] target Value supplied through the `target` argument.
1047 @return Result produced by this operation.
1048 """
1049 if target["artifact_type"] != "study-case":
1050 return []
1051 study_root = Path(target["study_path"])
1052 candidates = [
1053 study_root / "study.yml",
1054 study_root / "cluster.yml",
1055 study_root / "study_manifest.json",
1056 study_root / "scheduler" / "case_index.tsv",
1057 study_root / "scheduler" / "submission.json",
1058 ]
1059 captured = []
1060 for path in candidates:
1061 if not path.is_file() or path.stat().st_size > 5 * 1024 * 1024:
1062 continue
1063 captured.append({
1064 "path": path.relative_to(study_root).as_posix(),
1065 "mode": int(path.stat().st_mode & 0o7777),
1066 "content_base64": base64.b64encode(path.read_bytes()).decode("ascii"),
1067 })
1068 return captured
1069
1070
1071def _config_fingerprints(root: str) -> dict:
1072 """!
1073 @brief Hash small canonical YAML inputs stored under an artifact.
1074 @param[in] root Value supplied through the `root` argument.
1075 @return Result produced by this operation.
1076 """
1077 result = {}
1078 root_path = Path(root)
1079 candidates = set(root_path.glob("config/*.yml"))
1080 candidates.update(root_path.glob("base_configs/*.yml"))
1081 candidates.update(root_path.glob("cases/case_*/config/*.yml"))
1082 for path in sorted(candidates):
1083 result[path.relative_to(root).as_posix()] = _sha256_file(str(path))
1084 for name in ("study.yml", "cluster.yml"):
1085 path = root_path / name
1086 if path.is_file():
1087 result[name] = _sha256_file(str(path))
1088 return result
1089
1090
1091def _git_provenance(root: str) -> dict:
1092 """!
1093 @brief Record best-effort current source revision and dirty state.
1094 @param[in] root Value supplied through the `root` argument.
1095 @return Result produced by this operation.
1096 """
1097 result = {"commit": None, "dirty": None}
1098 try:
1099 commit = subprocess.run(
1100 ["git", "rev-parse", "HEAD"], cwd=root, text=True, capture_output=True, check=False
1101 )
1102 status = subprocess.run(
1103 ["git", "status", "--porcelain"], cwd=root, text=True, capture_output=True, check=False
1104 )
1105 if commit.returncode == 0:
1106 result["commit"] = commit.stdout.strip()
1107 if status.returncode == 0:
1108 result["dirty"] = bool(status.stdout.strip())
1109 except OSError:
1110 pass
1111 return result
1112
1113
1114def _parse_tags(raw_tags) -> dict:
1115 """!
1116 @brief Parse repeatable KEY=VALUE tags into deterministic metadata.
1117 @param[in] raw_tags Value supplied through the `raw_tags` argument.
1118 @return Result produced by this operation.
1119 """
1120 tags = {}
1121 for item in raw_tags or []:
1122 key, separator, value = str(item).partition("=")
1123 if not separator or not key.strip() or not value.strip():
1124 raise StorageError(f"Invalid tag {item!r}; expected KEY=VALUE.")
1125 tags[key.strip()] = value.strip()
1126 return tags
1127
1128
1129def build_storage_plan(target: dict, profile: dict, compression: str = None) -> dict:
1130 """!
1131 @brief Build the read-only plan consumed by protect and offload.
1132 @param[in] target Value supplied through the `target` argument.
1133 @param[in] profile Value supplied through the `profile` argument.
1134 @param[in] compression Value supplied through the `compression` argument.
1135 @return Result produced by this operation.
1136 """
1137 inventory = inspect_artifact(target)
1138 selected_compression = _select_compression(compression, inventory["total_bytes"], profile)
1139 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
1140 return {
1141 "inventory": inventory,
1142 "compression": selected_compression,
1143 "chunk_count": len(specs),
1144 "chunks": [
1145 {
1146 "component": spec["component"],
1147 "file_count": len(spec["entries"]),
1148 "uncompressed_bytes": spec["uncompressed_bytes"],
1149 }
1150 for spec in specs
1151 ],
1152 }
1153
1154
1155def _render_plan(plan: dict) -> None:
1156 """!
1157 @brief Print a concise archive/offload plan.
1158 @param[in] plan Value supplied through the `plan` argument.
1159 """
1160 inventory = plan["inventory"]
1161 target = inventory["target"]
1162 print(f"[INFO] Artifact type : {target['artifact_type']}")
1163 print(f"[INFO] Artifact path : {target['root_path']}")
1164 print(f"[INFO] Local size : {_human_bytes(inventory['total_bytes'])}")
1165 print(f"[INFO] Files : {inventory['file_count']}")
1166 print(f"[INFO] Checkpoints : {len(inventory['checkpoint_steps'])}")
1167 print(f"[INFO] Compression : {plan['compression']}")
1168 print(f"[INFO] Archive chunks: {plan['chunk_count']}")
1169 if inventory["external_paths"]:
1170 print("[WARNING] External configured paths are recorded but are not followed automatically:")
1171 for item in inventory["external_paths"]:
1172 print(f" - {item['source']}: {item['path']}")
1173 if inventory["dependencies"]:
1174 print("[WARNING] External run dependencies:")
1175 for item in inventory["dependencies"]:
1176 print(f" - {item['kind']}: {item['path']}")
1177
1178
1179def archive_artifact(target: dict, profile: dict, label: str = None, tags=None,
1180 compression: str = None, prune_local: bool = False) -> dict:
1181 """!
1182 @brief Package, upload, verify, register, and optionally prune one artifact.
1183 @param[in] target Value supplied through the `target` argument.
1184 @param[in] profile Value supplied through the `profile` argument.
1185 @param[in] label Value supplied through the `label` argument.
1186 @param[in] tags Value supplied through the `tags` argument.
1187 @param[in] compression Value supplied through the `compression` argument.
1188 @param[in] prune_local Value supplied through the `prune_local` argument.
1189 @return Result produced by this operation.
1190 """
1191 with storage_operation_lock(target["root_path"], "offload" if prune_local else "protect"):
1192 plan = build_storage_plan(target, profile, compression)
1193 inventory = plan["inventory"]
1194 _assert_archive_safe(inventory)
1195 archive_id = uuid.uuid4().hex
1196 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
1197 staging_parent = profile.get("staging_directory")
1198 if staging_parent:
1199 staging_parent = os.path.abspath(os.path.expanduser(str(staging_parent)))
1200 os.makedirs(staging_parent, exist_ok=True)
1201 print(f"[INFO] Creating archive {archive_id} for {target['root_path']}")
1202 with tempfile.TemporaryDirectory(prefix="picurv-storage-", dir=staging_parent) as staging:
1203 chunks = []
1204 for index, spec in enumerate(specs):
1205 component = _safe_component_name(spec["component"])
1206 filename = f"{index:05d}_{component}{_chunk_extension(plan['compression'])}"
1207 local_chunk = os.path.join(staging, filename)
1208 print(
1209 f"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
1210 f"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
1211 )
1212 _write_tar_chunk(target["root_path"], spec, local_chunk, plan["compression"])
1213 remote_path = _object_remote(profile, archive_id, "chunks", filename)
1214 verified = _upload_verified(local_chunk, remote_path)
1215 chunks.append({
1216 "name": filename,
1217 "component": spec["component"],
1218 "file_count": len(spec["entries"]),
1219 "uncompressed_bytes": spec["uncompressed_bytes"],
1220 "stored_bytes": verified["stored_bytes"],
1221 "sha256": verified["sha256"],
1222 })
1223
1224 manifest = {
1225 "storage_schema_version": STORAGE_SCHEMA_VERSION,
1226 "archive_id": archive_id,
1227 "created_at": _utc_now(),
1228 "artifact_type": target["artifact_type"],
1229 "run_id": target.get("run_id"),
1230 "study_id": target.get("study_id"),
1231 "case_id": target.get("case_id"),
1232 "label": label or os.path.basename(target["root_path"]),
1233 "tags": _parse_tags(tags),
1234 "original_path": target["original_path"],
1235 "original_study_path": target.get("study_path"),
1236 "profile": profile["name"],
1237 "remote": profile["remote"],
1238 "source_bytes": inventory["total_bytes"],
1239 "source_file_count": inventory["file_count"],
1240 "compression": plan["compression"],
1241 "checkpoint_format_version": KNOWN_CHECKPOINT_VERSION,
1242 "checkpoint_steps": inventory["checkpoint_steps"],
1243 "chunks": chunks,
1244 "config_sha256": _config_fingerprints(target["root_path"]),
1245 "git": _git_provenance(target["root_path"]),
1246 "external_paths": inventory["external_paths"],
1247 "dependencies": inventory["dependencies"],
1248 "study_context": _capture_study_context(target),
1249 "capabilities": {
1250 "restorable": True,
1251 "continuable": bool(inventory["checkpoint_steps"]),
1252 "reprocessable": bool(inventory["checkpoint_steps"]),
1253 "exact_binary_reproduction": False,
1254 },
1255 }
1256 manifest_path = os.path.join(staging, REMOTE_MANIFEST_FILENAME)
1257 _atomic_write_json(manifest_path, manifest)
1258 _upload_verified(manifest_path, _object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
1259 manifest_digest = _sha256_file(manifest_path)
1260 complete_path = os.path.join(staging, REMOTE_COMPLETE_FILENAME)
1261 with open(complete_path, "w", encoding="ascii") as stream:
1262 stream.write(manifest_digest + "\n")
1263 _upload_verified(complete_path, _object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
1264
1265 state = {
1266 "storage_schema_version": STORAGE_SCHEMA_VERSION,
1267 "archive_id": archive_id,
1268 "profile": profile["name"],
1269 "remote": profile["remote"],
1270 "label": manifest["label"],
1271 "archived_at": manifest["created_at"],
1272 "local_pruned": False,
1273 "restored_components": [],
1274 }
1275 _atomic_write_json(_state_path(target["root_path"]), state)
1276 if prune_local:
1277 _prune_archived_payload(target["root_path"], inventory)
1278 state["local_pruned"] = True
1279 state["pruned_at"] = _utc_now()
1280 _atomic_write_json(_state_path(target["root_path"]), state)
1281 print(
1282 f"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
1283 f"as archive {archive_id}."
1284 )
1285 return manifest
1286
1287
1288def _prune_archived_payload(root: str, inventory: dict) -> None:
1289 """!
1290 @brief Remove only verified heavy payload while retaining control-plane files.
1291 @param[in] root Value supplied through the `root` argument.
1292 @param[in] inventory Value supplied through the `inventory` argument.
1293 """
1294 removable = {"data"}
1295 removable.update(f"checkpoint:{step}" for step in inventory["checkpoint_steps"])
1296 for entry in sorted(inventory["entries"], key=lambda item: item["path"], reverse=True):
1297 if entry["component"] not in removable or entry["type"] == "directory":
1298 continue
1299 path = os.path.join(root, *entry["path"].split("/"))
1300 try:
1301 if os.path.islink(path) or os.path.isfile(path):
1302 os.remove(path)
1303 except FileNotFoundError:
1304 pass
1305 for entry in sorted(
1306 (item for item in inventory["entries"] if item["type"] == "directory"),
1307 key=lambda item: item["path"].count("/"), reverse=True,
1308 ):
1309 path = os.path.join(root, *entry["path"].split("/"))
1310 try:
1311 os.rmdir(path)
1312 except OSError:
1313 pass
1314
1315
1316def list_remote_manifests(profile: dict) -> list:
1317 """!
1318 @brief Enumerate completed archive manifests from the remote catalog.
1319 @param[in] profile Value supplied through the `profile` argument.
1320 @return Result produced by this operation.
1321 """
1322 root = _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)
1323 result = _run_rclone([
1324 "lsf", root, "--recursive", "--files-only", "--include", f"*/{REMOTE_MANIFEST_FILENAME}"
1325 ])
1326 manifests = []
1327 for relative in sorted(line.strip() for line in result.stdout.splitlines() if line.strip()):
1328 archive_id = relative.split("/", 1)[0]
1329 if not ARCHIVE_ID_PATTERN.fullmatch(archive_id):
1330 continue
1331 try:
1332 manifests.append(_load_remote_manifest(profile, archive_id))
1333 except StorageError:
1334 continue
1335 return manifests
1336
1337
1338def verify_remote_archive(profile: dict, archive_id: str) -> dict:
1339 """!
1340 @brief Verify the completion marker and every stored chunk checksum.
1341 @param[in] profile Value supplied through the `profile` argument.
1342 @param[in] archive_id Value supplied through the `archive_id` argument.
1343 @return Result produced by this operation.
1344 """
1345 manifest = _load_remote_manifest(profile, archive_id)
1346 for chunk in manifest.get("chunks", []):
1347 remote = _object_remote(profile, archive_id, "chunks", chunk["name"])
1348 actual = _remote_sha256(remote)
1349 if actual != chunk.get("sha256"):
1350 raise StorageError(
1351 f"Archive {archive_id} chunk checksum mismatch: {chunk['name']} "
1352 f"(expected {chunk.get('sha256')}, got {actual})."
1353 )
1354 return manifest
1355
1356
1357def _validate_tar_members(archive: tarfile.TarFile) -> None:
1358 """!
1359 @brief Reject archive members that could escape the restore destination.
1360 @param[in] archive Value supplied through the `archive` argument.
1361 """
1362 link_paths = set()
1363 members = archive.getmembers()
1364 for member in members:
1365 normalized = os.path.normpath(member.name.replace("\\", "/"))
1366 if normalized.startswith("../") or normalized == ".." or os.path.isabs(normalized):
1367 raise StorageError(f"Unsafe archive member path: {member.name}")
1368 for link_path in link_paths:
1369 if normalized == link_path or normalized.startswith(link_path.rstrip("/") + "/"):
1370 raise StorageError(f"Archive member traverses an earlier symlink: {member.name}")
1371 if member.issym() or member.islnk():
1372 if member.islnk():
1373 link_target = os.path.normpath(member.linkname.replace("\\", "/"))
1374 if link_target.startswith("../") or link_target == ".." or os.path.isabs(link_target):
1375 raise StorageError(f"Unsafe archive hardlink target: {member.linkname}")
1376 link_paths.add(normalized)
1377
1378
1379def _extract_chunk(path: str, destination: str) -> None:
1380 """!
1381 @brief Safely extract one verified tar chunk into a staging tree.
1382 @param[in] path Value supplied through the `path` argument.
1383 @param[in] destination Value supplied through the `destination` argument.
1384 """
1385 with tarfile.open(path, "r:*") as archive:
1386 _validate_tar_members(archive)
1387 try:
1388 archive.extractall(destination, filter="data")
1389 except TypeError:
1390 archive.extractall(destination)
1391
1392
1393def _restore_study_context(manifest: dict, case_destination: str) -> None:
1394 """!
1395 @brief Recreate missing study control-plane files around a restored member.
1396 @param[in] manifest Value supplied through the `manifest` argument.
1397 @param[in] case_destination Value supplied through the `case_destination` argument.
1398 """
1399 if manifest.get("artifact_type") != "study-case":
1400 return
1401 study_root = Path(case_destination).parent.parent
1402 for item in manifest.get("study_context", []):
1403 relative = item.get("path")
1404 if not isinstance(relative, str):
1405 continue
1406 destination = study_root.joinpath(*relative.split("/"))
1407 if destination.exists():
1408 continue
1409 destination.parent.mkdir(parents=True, exist_ok=True)
1410 destination.write_bytes(base64.b64decode(item.get("content_base64", "")))
1411 try:
1412 destination.chmod(int(item.get("mode", 0o644)))
1413 except OSError:
1414 pass
1415
1416
1417def _merge_tree(source: str, destination: str) -> None:
1418 """!
1419 @brief Merge a verified restore tree into a known cold artifact skeleton.
1420 @param[in] source Value supplied through the `source` argument.
1421 @param[in] destination Value supplied through the `destination` argument.
1422 """
1423 os.makedirs(destination, exist_ok=True)
1424 for entry in os.scandir(source):
1425 target = os.path.join(destination, entry.name)
1426 if entry.is_symlink():
1427 if os.path.lexists(target):
1428 if os.path.isdir(target) and not os.path.islink(target):
1429 shutil.rmtree(target)
1430 else:
1431 os.remove(target)
1432 os.symlink(os.readlink(entry.path), target)
1433 elif entry.is_dir(follow_symlinks=False):
1434 _merge_tree(entry.path, target)
1435 else:
1436 os.makedirs(os.path.dirname(target), exist_ok=True)
1437 shutil.copy2(entry.path, target)
1438
1439
1440def _rebase_restored_text_paths(root: str, replacements: list) -> list:
1441 """!
1442 @brief Rebase known generated text artifacts after an explicit relocated restore.
1443 @param[in] root Value supplied through the `root` argument.
1444 @param[in] replacements Value supplied through the `replacements` argument.
1445 @return Result produced by this operation.
1446 """
1447 changed = []
1448 allowed_suffixes = {".control", ".run", ".sbatch", ".json", ".tsv", ".yml", ".yaml"}
1449 for path in Path(root).rglob("*"):
1450 if not path.is_file() or path.is_symlink() or path.stat().st_size > 32 * 1024 * 1024:
1451 continue
1452 if path.suffix.lower() not in allowed_suffixes:
1453 continue
1454 try:
1455 content = path.read_text(encoding="utf-8")
1456 except (OSError, UnicodeDecodeError):
1457 continue
1458 updated = content
1459 for old, new in replacements:
1460 if old and new and old != new:
1461 updated = updated.replace(old, new)
1462 if updated != content:
1463 path.write_text(updated, encoding="utf-8")
1464 changed.append(str(path))
1465 return changed
1466
1467
1468def restore_archive(profile: dict, archive_id: str, destination: str = None,
1469 checkpoints=None, force: bool = False) -> dict:
1470 """!
1471 @brief Download, verify, extract, and materialize an archive or selected checkpoints.
1472 @param[in] profile Value supplied through the `profile` argument.
1473 @param[in] archive_id Value supplied through the `archive_id` argument.
1474 @param[in] destination Value supplied through the `destination` argument.
1475 @param[in] checkpoints Value supplied through the `checkpoints` argument.
1476 @param[in] force Value supplied through the `force` argument.
1477 @return Result produced by this operation.
1478 """
1479 manifest = _load_remote_manifest(profile, archive_id)
1480 original = os.path.abspath(manifest["original_path"])
1481 destination_abs = os.path.abspath(destination or original)
1482 selected_steps = {int(step) for step in (checkpoints or [])}
1483 chunks = []
1484 for chunk in manifest.get("chunks", []):
1485 component = str(chunk.get("component", ""))
1486 if selected_steps:
1487 if component == "metadata":
1488 chunks.append(chunk)
1489 elif component.startswith("checkpoint:") and int(component.split(":", 1)[1]) in selected_steps:
1490 chunks.append(chunk)
1491 else:
1492 chunks.append(chunk)
1493 if selected_steps:
1494 available = set(manifest.get("checkpoint_steps", []))
1495 missing = selected_steps - available
1496 if missing:
1497 raise StorageError(f"Archive {archive_id} does not contain checkpoint step(s): {sorted(missing)}")
1498 existing_state = read_storage_state(destination_abs) if os.path.isdir(destination_abs) else None
1499 if os.path.exists(destination_abs) and not force:
1500 if not existing_state or existing_state.get("archive_id") != archive_id:
1501 raise StorageError(
1502 f"Restore destination already exists and is not the matching cold artifact: {destination_abs}. "
1503 "Choose --to or use --force after verifying the destination."
1504 )
1505 parent = os.path.dirname(destination_abs)
1506 os.makedirs(parent, exist_ok=True)
1507 temporary = tempfile.mkdtemp(prefix=f".picurv-restore-{archive_id[:8]}-", dir=parent)
1508 materialized = os.path.join(temporary, "materialized")
1509 os.makedirs(materialized)
1510 try:
1511 for index, chunk in enumerate(chunks):
1512 local_chunk = os.path.join(temporary, chunk["name"])
1513 remote_chunk = _object_remote(profile, archive_id, "chunks", chunk["name"])
1514 print(f"[INFO] Restoring chunk {index + 1}/{len(chunks)}: {chunk['component']}")
1515 _run_rclone(["copyto", remote_chunk, local_chunk])
1516 actual = _sha256_file(local_chunk)
1517 if actual != chunk.get("sha256"):
1518 raise StorageError(
1519 f"Downloaded chunk checksum mismatch: {chunk['name']} "
1520 f"(expected {chunk.get('sha256')}, got {actual})."
1521 )
1522 _extract_chunk(local_chunk, materialized)
1523
1524 if os.path.isdir(destination_abs):
1525 _merge_tree(materialized, destination_abs)
1526 else:
1527 os.replace(materialized, destination_abs)
1528 _restore_study_context(manifest, destination_abs)
1529
1530 replacements = [(original, destination_abs)]
1531 original_study = manifest.get("original_study_path")
1532 if original_study and manifest.get("artifact_type") == "study-case":
1533 replacements.append((os.path.abspath(original_study), str(Path(destination_abs).parent.parent)))
1534 rebased = _rebase_restored_text_paths(destination_abs, replacements)
1535 state = {
1536 "storage_schema_version": STORAGE_SCHEMA_VERSION,
1537 "archive_id": archive_id,
1538 "profile": profile["name"],
1539 "remote": profile["remote"],
1540 "label": manifest.get("label"),
1541 "archived_at": manifest.get("created_at"),
1542 "restored_at": _utc_now(),
1543 "local_pruned": bool(selected_steps),
1544 "restored_components": [f"checkpoint:{step}" for step in sorted(selected_steps)],
1545 "relocated_from": original if original != destination_abs else None,
1546 "rebased_files": len(rebased),
1547 }
1548 _atomic_write_json(_state_path(destination_abs), state)
1549 except Exception:
1550 if os.path.isdir(temporary):
1551 shutil.rmtree(temporary, ignore_errors=True)
1552 raise
1553 if os.path.isdir(temporary):
1554 shutil.rmtree(temporary, ignore_errors=True)
1555 print(f"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
1556 if rebased:
1557 print(f"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
1558 return manifest
1559
1560
1562 """!
1563 @brief Resolve an explicit archive ID or a local artifact marker.
1564 @param[in] args Value supplied through the `args` argument.
1565 @return Result produced by this operation.
1566 """
1567 archive_id = getattr(args, "archive_id", None)
1568 if archive_id:
1569 return archive_id
1571 getattr(args, "run_dir", None), getattr(args, "study_dir", None), getattr(args, "case_ids", None)
1572 )
1573 if len(targets) != 1:
1574 raise StorageError("Restore/verify by local marker requires exactly one target.")
1575 state = read_storage_state(targets[0]["root_path"])
1576 if not state or not state.get("archive_id"):
1577 raise StorageError(f"No storage marker with an archive ID exists under {targets[0]['root_path']}.")
1578 return state["archive_id"]
1579
1580
1581def _render_status(inventory: dict) -> None:
1582 """!
1583 @brief Render one artifact status row and safety details.
1584 @param[in] inventory Value supplied through the `inventory` argument.
1585 """
1586 target = inventory["target"]
1587 storage = inventory["storage"]
1588 activity = "BUSY" if inventory["active_locks"] or inventory["slurm"]["active"] else storage["state"]
1589 identity = target.get("case_id") or target.get("run_id") or target.get("study_id")
1590 print(
1591 f"{identity:<36} {target['artifact_type']:<12} {activity:<10} "
1592 f"{_human_bytes(inventory['total_bytes']):>12} {storage.get('label') or ''}"
1593 )
1594
1595
1596def storage_setup_workflow(args) -> None:
1597 """!
1598 @brief Create or update a non-secret workspace storage profile.
1599 @param[in] args Value supplied through the `args` argument.
1600 """
1601 config_path = resolve_storage_config_path(getattr(args, "storage_config", None), require=False)
1602 payload = {}
1603 if os.path.isfile(config_path):
1604 with open(config_path, "r", encoding="utf-8") as stream:
1605 payload = yaml.safe_load(stream) or {}
1606 profiles = payload.setdefault("profiles", {})
1607 profile_name = args.profile or DEFAULT_PROFILE_NAME
1608 profile = {
1609 "remote": args.remote.rstrip("/"),
1610 "compression": args.compression,
1611 "chunk_size_gib": args.chunk_size_gib,
1612 }
1613 if args.staging_directory:
1614 profile["staging_directory"] = os.path.abspath(os.path.expanduser(args.staging_directory))
1615 payload["default_profile"] = profile_name
1616 profiles[profile_name] = profile
1617 print(f"[INFO] Storage config : {config_path}")
1618 print(f"[INFO] Profile : {profile_name}")
1619 print(f"[INFO] Remote : {profile['remote']}")
1620 if args.dry_run:
1621 print("[INFO] Dry-run only. No configuration or remote directories were changed.")
1622 return
1623 _run_rclone(["mkdir", _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)])
1624 os.makedirs(os.path.dirname(config_path), exist_ok=True)
1625 temporary = f"{config_path}.tmp.{os.getpid()}"
1626 with open(temporary, "w", encoding="utf-8") as stream:
1627 yaml.safe_dump(payload, stream, sort_keys=False)
1628 os.replace(temporary, config_path)
1629 print("[SUCCESS] Storage profile configured and remote access verified.")
1630
1631
1632def storage_status_workflow(args) -> None:
1633 """!
1634 @brief Print local storage and lifecycle status for selected artifacts.
1635 @param[in] args Value supplied through the `args` argument.
1636 """
1637 if args.study_dir and not args.case_ids:
1638 study_root = os.path.abspath(args.study_dir)
1639 case_ids = [
1640 path.name for path in sorted((Path(study_root) / "cases").glob("case_*")) if path.is_dir()
1641 ]
1642 targets = resolve_local_storage_targets(None, study_root, case_ids) if case_ids else resolve_local_storage_targets(None, study_root)
1643 else:
1644 targets = resolve_local_storage_targets(args.run_dir, args.study_dir, args.case_ids)
1645 inventories = [inspect_artifact(target) for target in targets]
1646 if args.output_format == "json":
1647 serializable = []
1648 for item in inventories:
1649 copy_item = dict(item)
1650 copy_item.pop("entries", None)
1651 serializable.append(copy_item)
1652 print(json.dumps(serializable, indent=2, sort_keys=True))
1653 return
1654 print(f"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1655 for inventory in inventories:
1656 _render_status(inventory)
1657
1658
1659def storage_plan_workflow(args) -> None:
1660 """!
1661 @brief Render a read-only packaging and safety plan.
1662 @param[in] args Value supplied through the `args` argument.
1663 """
1664 profile = load_storage_profile(args.profile, args.storage_config)
1665 targets = resolve_local_storage_targets(args.run_dir, args.study_dir, args.case_ids)
1666 for index, target in enumerate(targets):
1667 if index:
1668 print()
1669 plan = build_storage_plan(target, profile, args.compression)
1670 _render_plan(plan)
1671 _assert_archive_safe(plan["inventory"])
1672
1673
1674def storage_archive_workflow(args, prune_local: bool) -> None:
1675 """!
1676 @brief Execute protect or offload for one or more explicit local targets.
1677 @param[in] args Value supplied through the `args` argument.
1678 @param[in] prune_local Value supplied through the `prune_local` argument.
1679 """
1680 profile = load_storage_profile(args.profile, args.storage_config)
1681 targets = resolve_local_storage_targets(args.run_dir, args.study_dir, args.case_ids)
1682 for target in targets:
1683 if args.dry_run:
1684 _render_plan(build_storage_plan(target, profile, args.compression))
1685 print("[INFO] Dry-run only. No files were packaged, uploaded, or pruned.")
1686 continue
1688 target,
1689 profile,
1690 label=args.label,
1691 tags=args.tags,
1692 compression=args.compression,
1693 prune_local=prune_local,
1694 )
1695
1696
1698 """!
1699 @brief Restore a remote archive by globally unique ID or local marker.
1700 @param[in] args Value supplied through the `args` argument.
1701 """
1702 profile = load_storage_profile(args.profile, args.storage_config)
1703 archive_id = _resolve_archive_id_from_args(args)
1705 profile,
1706 archive_id,
1707 destination=args.destination,
1708 checkpoints=args.checkpoints,
1709 force=args.force,
1710 )
1711
1712
1713def storage_verify_workflow(args) -> None:
1714 """!
1715 @brief Verify all remote chunks for one archive.
1716 @param[in] args Value supplied through the `args` argument.
1717 """
1718 profile = load_storage_profile(args.profile, args.storage_config)
1719 archive_id = _resolve_archive_id_from_args(args)
1720 manifest = verify_remote_archive(profile, archive_id)
1721 print(
1722 f"[SUCCESS] Archive {archive_id} is complete; verified {len(manifest.get('chunks', []))} chunk(s)."
1723 )
1724
1725
1726def storage_list_workflow(args) -> None:
1727 """!
1728 @brief Search the remote manifest catalog without local artifact state.
1729 @param[in] args Value supplied through the `args` argument.
1730 """
1731 profile = load_storage_profile(args.profile, args.storage_config)
1732 manifests = list_remote_manifests(profile)
1733 query = str(args.search or "").lower()
1734 if query:
1735 manifests = [
1736 item for item in manifests
1737 if query in " ".join(
1738 str(item.get(key, "")) for key in ("archive_id", "label", "run_id", "study_id", "case_id", "tags")
1739 ).lower()
1740 ]
1741 if args.output_format == "json":
1742 print(json.dumps(manifests, indent=2, sort_keys=True))
1743 return
1744 print(f"{'ARCHIVE ID':<34} {'TYPE':<12} {'IDENTITY':<32} LABEL")
1745 for item in manifests:
1746 identity = item.get("case_id") or item.get("run_id") or item.get("study_id") or "-"
1747 print(f"{item['archive_id']:<34} {item.get('artifact_type', '-'):<12} {identity:<32} {item.get('label', '')}")
1748
1749
1750def storage_show_workflow(args) -> None:
1751 """!
1752 @brief Show one complete remote archive manifest.
1753 @param[in] args Value supplied through the `args` argument.
1754 """
1755 profile = load_storage_profile(args.profile, args.storage_config)
1756 manifest = _load_remote_manifest(profile, args.archive_id)
1757 print(json.dumps(manifest, indent=2, sort_keys=True))
1758
1759
1760def storage_workflow(args) -> None:
1761 """!
1762 @brief Dispatch nested storage actions using existing PICurv workflow conventions.
1763 @param[in] args Value supplied through the `args` argument.
1764 """
1765 try:
1766 action = args.storage_action
1767 if action == "setup":
1769 elif action == "status":
1771 elif action == "plan":
1773 elif action == "protect":
1774 storage_archive_workflow(args, prune_local=False)
1775 elif action == "offload":
1776 storage_archive_workflow(args, prune_local=True)
1777 elif action == "restore":
1779 elif action == "verify":
1781 elif action == "list":
1783 elif action == "show":
1785 else:
1786 raise StorageError(f"Unsupported storage action: {action}")
1787 except StorageError as exc:
1788 print(f"[FATAL] {exc}", file=sys.stderr)
1789 raise SystemExit(1)
1790
1791
1792def add_storage_parser(subparsers) -> argparse.ArgumentParser:
1793 """!
1794 @brief Attach the nested storage command parser to PICurv's top-level parser.
1795 @param[in] subparsers Value supplied through the `subparsers` argument.
1796 @return Result produced by this operation.
1797 """
1798 parser = subparsers.add_parser(
1799 "storage",
1800 help="Protect, offload, inspect, verify, and restore run/study artifacts.",
1801 formatter_class=argparse.RawTextHelpFormatter,
1802 description=(
1803 "Manage PICurv run and study data through a configured rclone remote.\n"
1804 "Remote archives are checksum-verified before local payload can be pruned.\n\n"
1805 "Examples:\n"
1806 " picurv storage setup --remote labstore:picurv-data\n"
1807 " picurv storage status --run-dir runs/my_run\n"
1808 " picurv storage protect --run-dir runs/my_run --label 'baseline'\n"
1809 " picurv storage offload --study-dir studies/my_study --case-id case_0003\n"
1810 " picurv storage list --search '64-grid'\n"
1811 " picurv storage restore --archive-id <id>"
1812 ),
1813 epilog="Use `picurv storage <action> --help` for action-specific controls.",
1814 )
1815 actions = parser.add_subparsers(dest="storage_action", required=True, help="Storage action")
1816
1817 setup = actions.add_parser("setup", help="Configure a non-secret rclone storage profile.")
1818 setup.add_argument("--remote", required=True, help="Rclone remote and base path, such as labstore:picurv-data.")
1819 setup.add_argument("--profile", default=DEFAULT_PROFILE_NAME, help="Profile name (default: archive).")
1820 setup.add_argument("--storage-config", help=f"Storage YAML path (default: ./{STORAGE_CONFIG_FILENAME}).")
1821 setup.add_argument("--compression", choices=["auto", "none", "fast", "balanced", "maximum"], default="auto")
1822 setup.add_argument("--chunk-size-gib", type=float, default=DEFAULT_CHUNK_SIZE_GIB)
1823 setup.add_argument("--staging-directory", help="Optional local directory for one archive chunk at a time.")
1824 setup.add_argument("--dry-run", action="store_true")
1825
1826 def add_profile_options(action_parser):
1827 """!
1828 @brief Attach shared storage-profile selectors to one action parser.
1829 @param[in] action_parser Value supplied through the `action_parser` argument.
1830 """
1831 action_parser.add_argument("--profile", help="Configured storage profile name.")
1832 action_parser.add_argument("--storage-config", help="Explicit storage YAML path.")
1833
1834 def add_local_target(action_parser, require=True):
1835 """!
1836 @brief Attach the standard run-or-study target selectors to one action parser.
1837 @param[in] action_parser Value supplied through the `action_parser` argument.
1838 @param[in] require Value supplied through the `require` argument.
1839 """
1840 group = action_parser.add_mutually_exclusive_group(required=require)
1841 group.add_argument("--run-dir", help="Standalone run directory.")
1842 group.add_argument("--study-dir", help="Sweep study directory.")
1843 action_parser.add_argument(
1844 "--case-id", dest="case_ids", action="append",
1845 help="One numbered study member, such as case_0003; repeat to select several.",
1846 )
1847
1848 status = actions.add_parser("status", help="Show local, protected, cold, and busy artifact state.")
1849 add_local_target(status)
1850 status.add_argument("--format", dest="output_format", choices=["text", "json"], default="text")
1851
1852 plan = actions.add_parser("plan", help="Show packaging, dependencies, and safety checks without writing.")
1853 add_local_target(plan)
1854 add_profile_options(plan)
1855 plan.add_argument("--compression", choices=["auto", "none", "fast", "balanced", "maximum"])
1856
1857 for name, help_text in (
1858 ("protect", "Upload and verify an archive while retaining all local files."),
1859 ("offload", "Upload and verify an archive, then prune heavy local payload."),
1860 ):
1861 action_parser = actions.add_parser(name, help=help_text)
1862 add_local_target(action_parser)
1863 add_profile_options(action_parser)
1864 action_parser.add_argument("--label", help="Human-readable searchable label.")
1865 action_parser.add_argument("--tag", dest="tags", action="append", help="Repeatable KEY=VALUE catalog tag.")
1866 action_parser.add_argument("--compression", choices=["auto", "none", "fast", "balanced", "maximum"])
1867 action_parser.add_argument("--dry-run", action="store_true")
1868
1869 restore = actions.add_parser("restore", help="Restore a complete archive or selected checkpoints.")
1870 restore_source = restore.add_mutually_exclusive_group(required=True)
1871 restore_source.add_argument("--archive-id", help="Globally unique remote archive ID.")
1872 restore_source.add_argument("--run-dir", help="Cold run containing a local storage marker.")
1873 restore_source.add_argument("--study-dir", help="Cold study containing a local storage marker.")
1874 restore.add_argument("--case-id", dest="case_ids", action="append")
1875 add_profile_options(restore)
1876 restore.add_argument("--to", dest="destination", help="Optional alternate restore destination.")
1877 restore.add_argument("--checkpoint", dest="checkpoints", action="append", type=int)
1878 restore.add_argument("--force", action="store_true", help="Allow merge into a non-matching existing destination.")
1879
1880 verify = actions.add_parser("verify", help="Verify a remote archive completion marker and chunk checksums.")
1881 verify_source = verify.add_mutually_exclusive_group(required=True)
1882 verify_source.add_argument("--archive-id")
1883 verify_source.add_argument("--run-dir")
1884 verify_source.add_argument("--study-dir")
1885 verify.add_argument("--case-id", dest="case_ids", action="append")
1886 add_profile_options(verify)
1887
1888 list_parser = actions.add_parser("list", help="List/search remote archives without local directories.")
1889 add_profile_options(list_parser)
1890 list_parser.add_argument("--search", help="Case-insensitive search across IDs, labels, identities, and tags.")
1891 list_parser.add_argument("--format", dest="output_format", choices=["text", "json"], default="text")
1892
1893 show = actions.add_parser("show", help="Print the complete manifest for one archive.")
1894 show.add_argument("--archive-id", required=True)
1895 add_profile_options(show)
1896 return parser
User-facing storage workflow failure.
Definition storage.py:50
dict _git_provenance(str root)
Record best-effort current source revision and dirty state.
Definition storage.py:1091
bool _path_is_within(str root, str candidate)
Return whether an absolute candidate remains within a root directory.
Definition storage.py:509
str _remote_sha256(str remote_path)
Ask rclone to calculate or retrieve the SHA-256 of one remote object.
Definition storage.py:247
_read_json(str path)
Read a JSON mapping when present, otherwise return None.
Definition storage.py:109
str resolve_storage_config_path(str explicit_path=None, bool require=True)
Resolve an explicit or nearest workspace storage configuration.
Definition storage.py:143
dict archive_artifact(dict target, dict profile, str label=None, tags=None, str compression=None, bool prune_local=False)
Package, upload, verify, register, and optionally prune one artifact.
Definition storage.py:1180
dict build_storage_plan(dict target, dict profile, str compression=None)
Build the read-only plan consumed by protect and offload.
Definition storage.py:1129
None storage_list_workflow(args)
Search the remote manifest catalog without local artifact state.
Definition storage.py:1726
list _artifact_runtime_roots(str root)
Return run-like roots contained by a standalone run or whole study.
Definition storage.py:532
None _restore_study_context(dict manifest, str case_destination)
Recreate missing study control-plane files around a restored member.
Definition storage.py:1393
list _build_chunk_specs(dict inventory, int chunk_size_bytes)
Group archive entries into independently transferable component chunks.
Definition storage.py:972
read_storage_state(str root_path)
Read the nearest applicable storage marker for a run, study, or study member.
Definition storage.py:335
list _find_incomplete_checkpoints(list entries)
Return incomplete checkpoint paths that make archival unsafe.
Definition storage.py:732
str _sha256_file(str path)
Calculate SHA-256 without loading a potentially large file into memory.
Definition storage.py:76
str _safe_component_name(str component)
Convert a component token into a portable archive filename fragment.
Definition storage.py:1006
None _validate_tar_members(tarfile.TarFile archive)
Reject archive members that could escape the restore destination.
Definition storage.py:1357
None _render_plan(dict plan)
Print a concise archive/offload plan.
Definition storage.py:1155
None _extract_chunk(str path, str destination)
Safely extract one verified tar chunk into a staging tree.
Definition storage.py:1379
list resolve_local_storage_targets(str run_dir=None, str study_dir=None, case_ids=None)
Resolve explicit run/study selectors into concrete artifact descriptions.
Definition storage.py:450
bool _lock_owner_active(str metadata_path)
Conservatively determine whether a solver/post/storage owner marker is active.
Definition storage.py:744
dict inspect_artifact(dict target, bool query_scheduler=True)
Build a read-only inventory and lifecycle assessment for one artifact.
Definition storage.py:825
str _utc_now()
Return a stable UTC timestamp for storage metadata.
Definition storage.py:54
None storage_show_workflow(args)
Show one complete remote archive manifest.
Definition storage.py:1750
list _discover_dependencies(str root)
Discover absolute restart/source paths embedded in generated controls.
Definition storage.py:598
dict load_storage_profile(str profile_name=None, str config_path=None)
Load and validate one non-secret rclone storage profile.
Definition storage.py:164
None _write_tar_chunk(str root, dict spec, str destination, str compression)
Package explicitly inventoried entries without following symlinks.
Definition storage.py:1015
dict _config_fingerprints(str root)
Hash small canonical YAML inputs stored under an artifact.
Definition storage.py:1071
str _select_compression(str requested, int total_bytes, dict profile)
Resolve automatic or configured compression policy.
Definition storage.py:942
None _render_status(dict inventory)
Render one artifact status row and safety details.
Definition storage.py:1581
None storage_status_workflow(args)
Print local storage and lifecycle status for selected artifacts.
Definition storage.py:1632
str _resolve_archive_id_from_args(args)
Resolve an explicit archive ID or a local artifact marker.
Definition storage.py:1561
list _walk_archive_entries(str root)
Enumerate archive entries without following symlinks.
Definition storage.py:627
list list_remote_manifests(dict profile)
Enumerate completed archive manifests from the remote catalog.
Definition storage.py:1316
_checkpoint_component(str relative_path)
Return a checkpoint component token for a path inside a committed step bundle.
Definition storage.py:678
None storage_verify_workflow(args)
Verify all remote chunks for one archive.
Definition storage.py:1713
None _prune_archived_payload(str root, dict inventory)
Remove only verified heavy payload while retaining control-plane files.
Definition storage.py:1288
None _merge_tree(str source, str destination)
Merge a verified restore tree into a known cold artifact skeleton.
Definition storage.py:1417
bytes _read_remote_bytes(str remote_path)
Read a small remote catalog object through rclone.
Definition storage.py:279
str _state_path(str root_path)
Return the local storage state marker path for an artifact root.
Definition storage.py:326
str _remote_join(str remote, *str parts)
Join path components without corrupting rclone remote syntax.
Definition storage.py:198
str _human_bytes(int value)
Format a byte count for concise command output.
Definition storage.py:62
storage_operation_lock(str root_path, str operation)
Hold an exclusive local storage-operation marker for one artifact.
Definition storage.py:883
dict restore_archive(dict profile, str archive_id, str destination=None, checkpoints=None, bool force=False)
Download, verify, extract, and materialize an archive or selected checkpoints.
Definition storage.py:1469
list _checkpoint_steps(list entries)
Return committed checkpoint steps represented in an inventory.
Definition storage.py:717
dict _load_remote_manifest(dict profile, str archive_id, bool require_complete=True)
Fetch and validate one versioned remote storage manifest.
Definition storage.py:297
str _chunk_extension(str compression)
Return the archive suffix for a compression policy.
Definition storage.py:963
list _capture_study_context(dict target)
Embed small study control-plane files with an individually archived member.
Definition storage.py:1043
str _object_remote(dict profile, str archive_id, *str parts)
Return the remote path for one immutable archive object.
Definition storage.py:214
_find_upwards(str start, str filename)
Find the nearest named file at or above a filesystem anchor.
Definition storage.py:123
None storage_restore_workflow(args)
Restore a remote archive by globally unique ID or local marker.
Definition storage.py:1697
dict _slurm_activity(str root)
Query live Slurm state for every job recorded below an artifact scheduler directory.
Definition storage.py:788
dict _parse_tags(raw_tags)
Parse repeatable KEY=VALUE tags into deterministic metadata.
Definition storage.py:1114
subprocess.CompletedProcess _run_rclone(list arguments, bool check=True)
Invoke rclone through the same argv-based subprocess boundary as other PICurv tools.
Definition storage.py:225
str _classify_component(str relative_path)
Classify one artifact path for packaging and local retention.
Definition storage.py:692
str _validate_case_id(str case_id)
Validate a canonical numbered study-member identifier.
Definition storage.py:439
None _atomic_write_json(str path, dict payload)
Atomically replace a JSON state or manifest file.
Definition storage.py:92
None _assert_archive_safe(dict inventory)
Refuse to package a changing or scheduler-ambiguous artifact.
Definition storage.py:858
None storage_setup_workflow(args)
Create or update a non-secret workspace storage profile.
Definition storage.py:1596
dict verify_remote_archive(dict profile, str archive_id)
Verify the completion marker and every stored chunk checksum.
Definition storage.py:1338
set _collect_job_ids(payload)
Recursively collect submitted Slurm job IDs from scheduler metadata.
Definition storage.py:770
None storage_archive_workflow(args, bool prune_local)
Execute protect or offload for one or more explicit local targets.
Definition storage.py:1674
None storage_plan_workflow(args)
Render a read-only packaging and safety plan.
Definition storage.py:1659
list _discover_external_paths(str root)
Report configured data paths that escape the archived directory boundary.
Definition storage.py:548
dict _upload_verified(str local_path, str remote_path)
Upload one file, then verify its remote SHA-256.
Definition storage.py:261
list _rebase_restored_text_paths(str root, list replacements)
Rebase known generated text artifacts after an explicit relocated restore.
Definition storage.py:1440
str _resolve_configured_path(str root, str value)
Resolve runtime-directory syntax using the run directory as working directory.
Definition storage.py:522