6@brief Lifecycle-aware archival, offload, verification, and restore workflows.
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.
28from pathlib
import Path
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
51 """! @brief User-facing storage workflow failure. """
56 @brief Return a stable UTC timestamp for storage metadata.
57 @return Result produced by this operation.
59 return datetime.datetime.now(datetime.timezone.utc).isoformat()
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.
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"
73 return f
"{int(value)} B"
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.
82 digest = hashlib.sha256()
83 with open(path,
"rb")
as stream:
85 block = stream.read(8 * 1024 * 1024)
89 return digest.hexdigest()
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.
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)
105 os.fsync(stream.fileno())
106 os.replace(temporary, path_abs)
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.
116 with open(path,
"r", encoding=
"utf-8")
as stream:
117 payload = json.load(stream)
118 except (OSError, ValueError):
120 return payload
if isinstance(payload, dict)
else None
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.
130 current = os.path.abspath(start)
131 if os.path.isfile(current):
132 current = os.path.dirname(current)
134 candidate = os.path.join(current, filename)
135 if os.path.isfile(candidate):
137 parent = os.path.dirname(current)
138 if parent == current:
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.
151 result = os.path.abspath(explicit_path)
154 if result
and os.path.isfile(result):
158 "No PICurv storage configuration was found. Run "
159 "'picurv storage setup --remote <rclone-remote:path>' first or pass --storage-config."
161 return os.path.abspath(explicit_path
or os.path.join(os.getcwd(), STORAGE_CONFIG_FILENAME))
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.
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
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)
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.
205 clean_parts = [str(part).strip(
"/")
for part
in parts
if str(part).strip(
"/")]
206 suffix =
"/".join(clean_parts)
209 if remote.endswith(
":"):
210 return remote + suffix
211 return remote.rstrip(
"/") +
"/" + suffix
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.
222 return _remote_join(profile[
"remote"], REMOTE_OBJECTS_DIRECTORY, archive_id, *parts)
225def _run_rclone(arguments: list, check: bool =
True) -> subprocess.CompletedProcess:
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.
232 executable = shutil.which(
"rclone")
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],
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}")
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.
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):
258 raise StorageError(f
"rclone did not return a SHA-256 for {remote_path}.")
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.
271 if remote_digest != local_digest:
273 f
"Remote checksum mismatch after upload: {remote_path} "
274 f
"(local {local_digest}, remote {remote_digest})."
276 return {
"sha256": local_digest,
"stored_bytes": os.path.getsize(local_path)}
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.
285 executable = shutil.which(
"rclone")
288 result = subprocess.run(
289 [executable,
"cat", remote_path], capture_output=
True, check=
False
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}")
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.
305 if not ARCHIVE_ID_PATTERN.fullmatch(str(archive_id)):
306 raise StorageError(f
"Invalid archive ID: {archive_id!r}.")
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.")
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:
320 f
"Archive {archive_id} uses unsupported storage schema "
321 f
"{manifest.get('storage_schema_version') if isinstance(manifest, dict) else 'unknown'}."
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.
332 return os.path.join(os.path.abspath(root_path), STORAGE_STATE_FILENAME)
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.
341 root = Path(os.path.abspath(root_path))
348 if root.parent.name ==
"cases":
353def is_artifact_cold(root_path: str) -> bool:
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.
360 return bool(state
and state.get(
"local_pruned"))
363def cold_study_members(study_path: str) -> list:
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.
369 cases_dir = Path(os.path.abspath(study_path)) /
"cases"
370 if not cases_dir.is_dir():
373 child.name
for child
in sorted(cases_dir.iterdir())
374 if child.is_dir()
and is_artifact_cold(str(child))
378def require_storage_payload_local(
381 checkpoint: int =
None,
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.
392 if not state
or not state.get(
"local_pruned"):
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
403 if required_steps
and not missing_steps:
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)
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}"
417def storage_state_summary(root_path: str) -> dict:
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.
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"
432 "archive_id": state.get(
"archive_id"),
433 "label": state.get(
"label"),
434 "profile": state.get(
"profile"),
435 "remote": state.get(
"remote"),
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.
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.")
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.
458 if bool(run_dir) == bool(study_dir):
459 raise StorageError(
"Select exactly one of --run-dir or --study-dir.")
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):
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}")
469 "artifact_type":
"run",
471 "original_path": root,
472 "run_id": os.path.basename(root),
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}")
484 for raw_case_id
in case_ids:
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}")
490 "artifact_type":
"study-case",
491 "root_path": case_root,
492 "original_path": case_root,
494 "study_id": os.path.basename(study_root),
496 "study_path": study_root,
500 "artifact_type":
"study",
501 "root_path": study_root,
502 "original_path": study_root,
504 "study_id": os.path.basename(study_root),
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.
517 return os.path.commonpath([os.path.abspath(root), os.path.abspath(candidate)]) == os.path.abspath(root)
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.
529 return os.path.abspath(value
if os.path.isabs(value)
else os.path.join(root, value))
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.
538 root_path = Path(os.path.abspath(root))
539 case_root = root_path /
"cases"
540 if case_root.is_dir():
542 str(path)
for path
in sorted(case_root.glob(
"case_*"))
545 return [str(root_path)]
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.
554 archive_root = os.path.abspath(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):
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():
572 "source": f
"{source_prefix}monitor.io.directories.{key}",
575 except (OSError, ValueError, TypeError):
577 post_path = os.path.join(config_dir,
"post.yml")
578 if not os.path.isfile(post_path):
581 with open(post_path,
"r", encoding=
"utf-8")
as stream:
582 post = yaml.safe_load(stream)
or {}
584 (
"post.io.output_directory", (post.get(
"io")
or {}).get(
"output_directory")),
585 (
"post.source_data.directory", (post.get(
"source_data")
or {}).get(
"directory")),
587 for source, value
in values:
588 if not isinstance(value, str)
or not value.strip()
or value ==
"<solver_output_dir>":
592 external.append({
"source": f
"{source_prefix}{source}",
"path": resolved})
593 except (OSError, ValueError, TypeError):
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.
605 archive_root = os.path.abspath(root)
608 controls.extend(sorted(Path(runtime_root).glob(
"config/*.control")))
609 for control
in controls:
611 lines = control.read_text(encoding=
"utf-8", errors=
"replace").splitlines()
615 stripped = line.strip()
616 if not stripped.startswith(
"-restart_dir "):
619 tokens = __import__(
"shlex").split(stripped)
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])})
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.
633 root_abs = os.path.abspath(root)
636 def visit(directory: str):
638 @brief Recursively inventory directory entries without following symlinks.
639 @param[in] directory Value supplied through the `directory` argument.
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}:
648 rel = os.path.relpath(child.path, root_abs).replace(os.sep,
"/")
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"
656 elif child.is_dir(follow_symlinks=
False):
657 entry_type =
"directory"
659 elif child.is_file(follow_symlinks=
False):
661 size = int(stat_result.st_size)
663 raise StorageError(f
"Unsupported filesystem entry in artifact: {child.path}")
668 "mode": int(stat_result.st_mode & 0o7777),
669 "mtime_ns": int(stat_result.st_mtime_ns),
671 if entry_type ==
"directory":
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.
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))}"
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.
701 parts = relative_path.split(
"/")
703 base = os.path.basename(relative_path)
704 if len(parts) >= 3
and parts[0] ==
"cases" and re.fullmatch(
r"case_\d+", parts[1]):
706 if first
in {
"config",
"scheduler"}
or base
in {
707 "manifest.json",
"study_manifest.json",
"study.yml",
"cluster.yml"
710 if first ==
"results":
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.
724 for entry
in entries:
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)
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.
739 entry[
"path"]
for entry
in entries
740 if any(INCOMPLETE_CHECKPOINT_PATTERN.match(part)
for part
in entry[
"path"].split(
"/"))
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.
753 host = owner.get(
"host")
754 pid = owner.get(
"pid")
755 if host
and host != socket.gethostname():
759 except (TypeError, ValueError):
763 except ProcessLookupError:
765 except (PermissionError, OSError):
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.
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():
782 elif isinstance(payload, list):
783 for value
in payload:
785 return {item
for item
in result
if item}
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.
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():
801 for path
in scheduler.glob(
"submission*.json"):
804 return {
"job_ids": [],
"active": [],
"unknown":
False}
805 squeue = shutil.which(
"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"],
814 if result.returncode != 0:
815 return {
"job_ids": sorted(job_ids),
"active": [],
"unknown":
True}
817 for line
in result.stdout.splitlines():
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}
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.
832 root = target[
"root_path"]
834 for entry
in entries:
837 for name
in (
"post.lock.json",
"solver.lock.json"):
838 for candidate
in Path(root).glob(f
"**/scheduler/{name}"):
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)
844 "target": dict(target),
846 "file_count": sum(entry[
"type"]
in {
"file",
"symlink"}
for entry
in entries),
847 "total_bytes": sum(entry[
"size"]
for entry
in entries),
850 "active_locks": sorted(lock_paths),
860 @brief Refuse to package a changing or scheduler-ambiguous artifact.
861 @param[in] inventory Value supplied through the `inventory` argument.
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"]:
870 "active Slurm job(s): " +
", ".join(
871 f
"{item['job_id']} ({item['state']})" for item
in inventory[
"slurm"][
"active"]
874 if inventory[
"slurm"][
"unknown"]:
876 "recorded Slurm job IDs could not be checked because squeue is unavailable or failed"
879 raise StorageError(
"Artifact is not safe to archive/offload: " +
"; ".join(problems) +
".")
882@contextlib.contextmanager
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.
889 root = os.path.abspath(root_path)
890 lock_path = os.path.join(root, STORAGE_LOCK_FILENAME)
891 if os.path.exists(lock_path):
893 raise StorageError(f
"Another storage operation owns {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
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)
909 except FileNotFoundError:
913@contextlib.contextmanager
914def runtime_stage_lock(root_path: str, stage: str):
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.
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):
925 raise StorageError(f
"A {stage} runtime stage already owns {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)
938 except FileNotFoundError:
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.
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":
956 if total_bytes < AUTO_NO_COMPRESSION_BYTES:
958 if total_bytes >= AUTO_MAXIMUM_COMPRESSION_BYTES:
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.
969 return {
"none":
".tar",
"fast":
".tar.gz",
"balanced":
".tar.gz",
"maximum":
".tar.xz"}[compression]
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.
981 for entry
in inventory[
"entries"]:
982 if entry[
"type"] ==
"directory":
983 directories.append(entry[
"path"])
985 groups.setdefault(entry[
"component"], []).append(entry)
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:
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})
999 current.append(entry[
"path"])
1000 current_bytes += int(entry[
"size"])
1002 specs.append({
"component": component,
"entries": current,
"uncompressed_bytes": current_bytes})
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.
1012 return re.sub(
r"[^A-Za-z0-9_.-]+",
"-", component).strip(
"-")
or "data"
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.
1024 if compression ==
"none":
1026 elif compression ==
"fast":
1028 kwargs[
"compresslevel"] = 1
1029 elif compression ==
"balanced":
1031 kwargs[
"compresslevel"] = 6
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)
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.
1049 if target[
"artifact_type"] !=
"study-case":
1051 study_root = Path(target[
"study_path"])
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",
1060 for path
in candidates:
1061 if not path.is_file()
or path.stat().st_size > 5 * 1024 * 1024:
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"),
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.
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
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.
1097 result = {
"commit":
None,
"dirty":
None}
1099 commit = subprocess.run(
1100 [
"git",
"rev-parse",
"HEAD"], cwd=root, text=
True, capture_output=
True, check=
False
1102 status = subprocess.run(
1103 [
"git",
"status",
"--porcelain"], cwd=root, text=
True, capture_output=
True, check=
False
1105 if commit.returncode == 0:
1106 result[
"commit"] = commit.stdout.strip()
1107 if status.returncode == 0:
1108 result[
"dirty"] = bool(status.stdout.strip())
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.
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()
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.
1138 selected_compression =
_select_compression(compression, inventory[
"total_bytes"], profile)
1141 "inventory": inventory,
1142 "compression": selected_compression,
1143 "chunk_count": len(specs),
1146 "component": spec[
"component"],
1147 "file_count": len(spec[
"entries"]),
1148 "uncompressed_bytes": spec[
"uncompressed_bytes"],
1157 @brief Print a concise archive/offload plan.
1158 @param[in] plan Value supplied through the `plan` argument.
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']}")
1180 compression: str =
None, prune_local: bool =
False) -> dict:
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.
1193 inventory = plan[
"inventory"]
1195 archive_id = uuid.uuid4().hex
1197 staging_parent = profile.get(
"staging_directory")
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:
1204 for index, spec
in enumerate(specs):
1206 filename = f
"{index:05d}_{component}{_chunk_extension(plan['compression'])}"
1207 local_chunk = os.path.join(staging, filename)
1209 f
"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
1210 f
"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
1212 _write_tar_chunk(target[
"root_path"], spec, local_chunk, plan[
"compression"])
1213 remote_path =
_object_remote(profile, archive_id,
"chunks", 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"],
1225 "storage_schema_version": STORAGE_SCHEMA_VERSION,
1226 "archive_id": archive_id,
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"]),
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"],
1246 "external_paths": inventory[
"external_paths"],
1247 "dependencies": inventory[
"dependencies"],
1251 "continuable": bool(inventory[
"checkpoint_steps"]),
1252 "reprocessable": bool(inventory[
"checkpoint_steps"]),
1253 "exact_binary_reproduction":
False,
1256 manifest_path = os.path.join(staging, REMOTE_MANIFEST_FILENAME)
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")
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": [],
1278 state[
"local_pruned"] =
True
1282 f
"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
1283 f
"as archive {archive_id}."
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.
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":
1299 path = os.path.join(root, *entry[
"path"].split(
"/"))
1301 if os.path.islink(path)
or os.path.isfile(path):
1303 except FileNotFoundError:
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,
1309 path = os.path.join(root, *entry[
"path"].split(
"/"))
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.
1322 root =
_remote_join(profile[
"remote"], REMOTE_OBJECTS_DIRECTORY)
1324 "lsf", root,
"--recursive",
"--files-only",
"--include", f
"*/{REMOTE_MANIFEST_FILENAME}"
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):
1333 except StorageError:
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.
1346 for chunk
in manifest.get(
"chunks", []):
1347 remote =
_object_remote(profile, archive_id,
"chunks", chunk[
"name"])
1349 if actual != chunk.get(
"sha256"):
1351 f
"Archive {archive_id} chunk checksum mismatch: {chunk['name']} "
1352 f
"(expected {chunk.get('sha256')}, got {actual})."
1359 @brief Reject archive members that could escape the restore destination.
1360 @param[in] archive Value supplied through the `archive` argument.
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():
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)
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.
1385 with tarfile.open(path,
"r:*")
as archive:
1388 archive.extractall(destination, filter=
"data")
1390 archive.extractall(destination)
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.
1399 if manifest.get(
"artifact_type") !=
"study-case":
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):
1406 destination = study_root.joinpath(*relative.split(
"/"))
1407 if destination.exists():
1409 destination.parent.mkdir(parents=
True, exist_ok=
True)
1410 destination.write_bytes(base64.b64decode(item.get(
"content_base64",
"")))
1412 destination.chmod(int(item.get(
"mode", 0o644)))
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.
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)
1432 os.symlink(os.readlink(entry.path), target)
1433 elif entry.is_dir(follow_symlinks=
False):
1436 os.makedirs(os.path.dirname(target), exist_ok=
True)
1437 shutil.copy2(entry.path, target)
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.
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:
1452 if path.suffix.lower()
not in allowed_suffixes:
1455 content = path.read_text(encoding=
"utf-8")
1456 except (OSError, UnicodeDecodeError):
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))
1469 checkpoints=
None, force: bool =
False) -> dict:
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.
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 [])}
1484 for chunk
in manifest.get(
"chunks", []):
1485 component = str(chunk.get(
"component",
""))
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)
1492 chunks.append(chunk)
1494 available = set(manifest.get(
"checkpoint_steps", []))
1495 missing = selected_steps - available
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:
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."
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)
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])
1517 if actual != chunk.get(
"sha256"):
1519 f
"Downloaded chunk checksum mismatch: {chunk['name']} "
1520 f
"(expected {chunk.get('sha256')}, got {actual})."
1524 if os.path.isdir(destination_abs):
1527 os.replace(materialized, destination_abs)
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)))
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"),
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),
1550 if os.path.isdir(temporary):
1551 shutil.rmtree(temporary, ignore_errors=
True)
1553 if os.path.isdir(temporary):
1554 shutil.rmtree(temporary, ignore_errors=
True)
1555 print(f
"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
1557 print(f
"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
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.
1567 archive_id = getattr(args,
"archive_id",
None)
1571 getattr(args,
"run_dir",
None), getattr(args,
"study_dir",
None), getattr(args,
"case_ids",
None)
1573 if len(targets) != 1:
1574 raise StorageError(
"Restore/verify by local marker requires exactly one target.")
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"]
1583 @brief Render one artifact status row and safety details.
1584 @param[in] inventory Value supplied through the `inventory` argument.
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")
1591 f
"{identity:<36} {target['artifact_type']:<12} {activity:<10} "
1592 f
"{_human_bytes(inventory['total_bytes']):>12} {storage.get('label') or ''}"
1598 @brief Create or update a non-secret workspace storage profile.
1599 @param[in] args Value supplied through the `args` argument.
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
1609 "remote": args.remote.rstrip(
"/"),
1610 "compression": args.compression,
1611 "chunk_size_gib": args.chunk_size_gib,
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']}")
1621 print(
"[INFO] Dry-run only. No configuration or remote directories were changed.")
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.")
1634 @brief Print local storage and lifecycle status for selected artifacts.
1635 @param[in] args Value supplied through the `args` argument.
1637 if args.study_dir
and not args.case_ids:
1638 study_root = os.path.abspath(args.study_dir)
1640 path.name
for path
in sorted((Path(study_root) /
"cases").glob(
"case_*"))
if path.is_dir()
1646 if args.output_format ==
"json":
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))
1654 print(f
"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1655 for inventory
in inventories:
1661 @brief Render a read-only packaging and safety plan.
1662 @param[in] args Value supplied through the `args` argument.
1666 for index, target
in enumerate(targets):
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.
1682 for target
in targets:
1685 print(
"[INFO] Dry-run only. No files were packaged, uploaded, or pruned.")
1692 compression=args.compression,
1693 prune_local=prune_local,
1699 @brief Restore a remote archive by globally unique ID or local marker.
1700 @param[in] args Value supplied through the `args` argument.
1707 destination=args.destination,
1708 checkpoints=args.checkpoints,
1715 @brief Verify all remote chunks for one archive.
1716 @param[in] args Value supplied through the `args` argument.
1722 f
"[SUCCESS] Archive {archive_id} is complete; verified {len(manifest.get('chunks', []))} chunk(s)."
1728 @brief Search the remote manifest catalog without local artifact state.
1729 @param[in] args Value supplied through the `args` argument.
1733 query = str(args.search
or "").lower()
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")
1741 if args.output_format ==
"json":
1742 print(json.dumps(manifests, indent=2, sort_keys=
True))
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', '')}")
1752 @brief Show one complete remote archive manifest.
1753 @param[in] args Value supplied through the `args` argument.
1757 print(json.dumps(manifest, indent=2, sort_keys=
True))
1760def storage_workflow(args) -> None:
1762 @brief Dispatch nested storage actions using existing PICurv workflow conventions.
1763 @param[in] args Value supplied through the `args` argument.
1766 action = args.storage_action
1767 if action ==
"setup":
1769 elif action ==
"status":
1771 elif action ==
"plan":
1773 elif action ==
"protect":
1775 elif action ==
"offload":
1777 elif action ==
"restore":
1779 elif action ==
"verify":
1781 elif action ==
"list":
1783 elif action ==
"show":
1786 raise StorageError(f
"Unsupported storage action: {action}")
1787 except StorageError
as exc:
1788 print(f
"[FATAL] {exc}", file=sys.stderr)
1792def add_storage_parser(subparsers) -> argparse.ArgumentParser:
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.
1798 parser = subparsers.add_parser(
1800 help=
"Protect, offload, inspect, verify, and restore run/study artifacts.",
1801 formatter_class=argparse.RawTextHelpFormatter,
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"
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>"
1813 epilog=
"Use `picurv storage <action> --help` for action-specific controls.",
1815 actions = parser.add_subparsers(dest=
"storage_action", required=
True, help=
"Storage action")
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")
1826 def add_profile_options(action_parser):
1828 @brief Attach shared storage-profile selectors to one action parser.
1829 @param[in] action_parser Value supplied through the `action_parser` argument.
1831 action_parser.add_argument(
"--profile", help=
"Configured storage profile name.")
1832 action_parser.add_argument(
"--storage-config", help=
"Explicit storage YAML path.")
1834 def add_local_target(action_parser, require=True):
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.
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.",
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")
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"])
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."),
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")
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.")
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)
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")
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)
User-facing storage workflow failure.
dict _git_provenance(str root)
Record best-effort current source revision and dirty state.
bool _path_is_within(str root, str candidate)
Return whether an absolute candidate remains within a root directory.
str _remote_sha256(str remote_path)
Ask rclone to calculate or retrieve the SHA-256 of one remote object.
_read_json(str path)
Read a JSON mapping when present, otherwise return None.
str resolve_storage_config_path(str explicit_path=None, bool require=True)
Resolve an explicit or nearest workspace storage configuration.
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.
dict build_storage_plan(dict target, dict profile, str compression=None)
Build the read-only plan consumed by protect and offload.
None storage_list_workflow(args)
Search the remote manifest catalog without local artifact state.
list _artifact_runtime_roots(str root)
Return run-like roots contained by a standalone run or whole study.
None _restore_study_context(dict manifest, str case_destination)
Recreate missing study control-plane files around a restored member.
list _build_chunk_specs(dict inventory, int chunk_size_bytes)
Group archive entries into independently transferable component chunks.
read_storage_state(str root_path)
Read the nearest applicable storage marker for a run, study, or study member.
list _find_incomplete_checkpoints(list entries)
Return incomplete checkpoint paths that make archival unsafe.
str _sha256_file(str path)
Calculate SHA-256 without loading a potentially large file into memory.
str _safe_component_name(str component)
Convert a component token into a portable archive filename fragment.
None _validate_tar_members(tarfile.TarFile archive)
Reject archive members that could escape the restore destination.
None _render_plan(dict plan)
Print a concise archive/offload plan.
None _extract_chunk(str path, str destination)
Safely extract one verified tar chunk into a staging tree.
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.
bool _lock_owner_active(str metadata_path)
Conservatively determine whether a solver/post/storage owner marker is active.
dict inspect_artifact(dict target, bool query_scheduler=True)
Build a read-only inventory and lifecycle assessment for one artifact.
str _utc_now()
Return a stable UTC timestamp for storage metadata.
None storage_show_workflow(args)
Show one complete remote archive manifest.
list _discover_dependencies(str root)
Discover absolute restart/source paths embedded in generated controls.
dict load_storage_profile(str profile_name=None, str config_path=None)
Load and validate one non-secret rclone storage profile.
None _write_tar_chunk(str root, dict spec, str destination, str compression)
Package explicitly inventoried entries without following symlinks.
dict _config_fingerprints(str root)
Hash small canonical YAML inputs stored under an artifact.
str _select_compression(str requested, int total_bytes, dict profile)
Resolve automatic or configured compression policy.
None _render_status(dict inventory)
Render one artifact status row and safety details.
None storage_status_workflow(args)
Print local storage and lifecycle status for selected artifacts.
str _resolve_archive_id_from_args(args)
Resolve an explicit archive ID or a local artifact marker.
list _walk_archive_entries(str root)
Enumerate archive entries without following symlinks.
list list_remote_manifests(dict profile)
Enumerate completed archive manifests from the remote catalog.
_checkpoint_component(str relative_path)
Return a checkpoint component token for a path inside a committed step bundle.
None storage_verify_workflow(args)
Verify all remote chunks for one archive.
None _prune_archived_payload(str root, dict inventory)
Remove only verified heavy payload while retaining control-plane files.
None _merge_tree(str source, str destination)
Merge a verified restore tree into a known cold artifact skeleton.
bytes _read_remote_bytes(str remote_path)
Read a small remote catalog object through rclone.
str _state_path(str root_path)
Return the local storage state marker path for an artifact root.
str _remote_join(str remote, *str parts)
Join path components without corrupting rclone remote syntax.
str _human_bytes(int value)
Format a byte count for concise command output.
storage_operation_lock(str root_path, str operation)
Hold an exclusive local storage-operation marker for one artifact.
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.
list _checkpoint_steps(list entries)
Return committed checkpoint steps represented in an inventory.
dict _load_remote_manifest(dict profile, str archive_id, bool require_complete=True)
Fetch and validate one versioned remote storage manifest.
str _chunk_extension(str compression)
Return the archive suffix for a compression policy.
list _capture_study_context(dict target)
Embed small study control-plane files with an individually archived member.
str _object_remote(dict profile, str archive_id, *str parts)
Return the remote path for one immutable archive object.
_find_upwards(str start, str filename)
Find the nearest named file at or above a filesystem anchor.
None storage_restore_workflow(args)
Restore a remote archive by globally unique ID or local marker.
dict _slurm_activity(str root)
Query live Slurm state for every job recorded below an artifact scheduler directory.
dict _parse_tags(raw_tags)
Parse repeatable KEY=VALUE tags into deterministic metadata.
subprocess.CompletedProcess _run_rclone(list arguments, bool check=True)
Invoke rclone through the same argv-based subprocess boundary as other PICurv tools.
str _classify_component(str relative_path)
Classify one artifact path for packaging and local retention.
str _validate_case_id(str case_id)
Validate a canonical numbered study-member identifier.
None _atomic_write_json(str path, dict payload)
Atomically replace a JSON state or manifest file.
None _assert_archive_safe(dict inventory)
Refuse to package a changing or scheduler-ambiguous artifact.
None storage_setup_workflow(args)
Create or update a non-secret workspace storage profile.
dict verify_remote_archive(dict profile, str archive_id)
Verify the completion marker and every stored chunk checksum.
set _collect_job_ids(payload)
Recursively collect submitted Slurm job IDs from scheduler metadata.
None storage_archive_workflow(args, bool prune_local)
Execute protect or offload for one or more explicit local targets.
None storage_plan_workflow(args)
Render a read-only packaging and safety plan.
list _discover_external_paths(str root)
Report configured data paths that escape the archived directory boundary.
dict _upload_verified(str local_path, str remote_path)
Upload one file, then verify its remote SHA-256.
list _rebase_restored_text_paths(str root, list replacements)
Rebase known generated text artifacts after an explicit relocated restore.
str _resolve_configured_path(str root, str value)
Resolve runtime-directory syntax using the run directory as working directory.