PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
operations.py
Go to the documentation of this file.
1"""!
2@file operations.py
3@brief Plan, protect, offload, restore, prune, and the command surface.
4"""
5
6import argparse
7import base64
8import concurrent.futures
9import contextlib
10import datetime
11import errno
12import hashlib
13import json
14import os
15import re
16import shutil
17import socket
18import subprocess
19import sys
20import tarfile
21import tempfile
22import uuid
23from pathlib import Path
24import yaml
25from .models import (
26 ALWAYS_RESTORED_COMPONENTS,
27 CLI_OUTPUT_FORMATS,
28 DEFAULT_CHUNK_SIZE_GIB,
29 DEFAULT_PROFILE_NAME,
30 DEFAULT_STORAGE_WORKERS,
31 KNOWN_CHECKPOINT_VERSION,
32 REMOTE_COMPLETE_FILENAME,
33 REMOTE_MANIFEST_FILENAME,
34 REMOTE_OBJECTS_DIRECTORY,
35 STORAGE_COMPRESSION_POLICIES,
36 STORAGE_CONFIG_FILENAME,
37 STORAGE_RETENTION_COMPONENTS,
38 storage_config_origin,
39 storage_workspace_root,
40 STORAGE_OFFLOAD_POLICIES,
41 STORAGE_RESTORE_COMPONENTS,
42 STORAGE_SCHEMA_VERSION,
43 StorageError,
44 UNCLASSIFIED_COMPONENT,
45 WORKSPACE_CONFIG_FILENAME,
46 _PARALLEL_GZIP_VALUES,
47 _atomic_write_json,
48 _find_upwards,
49 _human_bytes,
50 _parse_tags,
51 _sha256_file,
52 _state_path,
53 _utc_now,
54 load_storage_profile,
55 read_storage_state,
56 resolve_storage_config_path,
57)
58from .transport import (
59 _blob_remote,
60 _chunk_remote_path,
61 _load_remote_manifest,
62 _object_remote,
63 _remote_blob_present,
64 _remote_join,
65)
66from .safety import (
67 _assert_archive_safe,
68 _require_free_space,
69 storage_operation_lock,
70)
71from .inventory import (
72 _inventory_fingerprint,
73 _select_completed_case_ids,
74 inspect_artifact,
75 resolve_local_storage_targets,
76)
77from .packaging import (
78 _build_chunk_specs,
79 _chunk_extension,
80 _compression_size_range,
81 _entry_retained_by_policy,
82 _extract_chunk,
83 _merge_tree,
84 _resolve_offload_policy,
85 _safe_component_name,
86 _select_compression,
87 _write_tar_chunk,
88)
89from .compatibility import (
90 _capture_parameter_summary,
91 _capture_run_assets,
92 _capture_study_context,
93 _capture_workspace_assets,
94 _config_fingerprints,
95 _git_provenance,
96 _rebase_restored_text_paths,
97 _restore_study_context,
98)
99from .catalog import (
100 _find_reusable_archive,
101 list_remote_manifests,
102 prune_unused_workspace_assets,
103 resolve_workspace_archive_id,
104 verify_remote_archive,
105)
106from . import transport as _transport
107
108
109def restore_cold_study_members(study_path: str, case_ids, profile_name: str = None,
110 storage_config: str = None) -> list:
111 """!
112 @brief Restore named cold study members in place from their local markers.
113 @param[in] study_path Study directory owning the members.
114 @param[in] case_ids Member ids to restore.
115 @param[in] profile_name Optional storage profile name.
116 @param[in] storage_config Optional explicit storage configuration path.
117 @return Restored member ids.
118 @throws StorageError when a member carries no usable archive reference.
119 """
120 # This runs inside a staging workflow rather than a storage command, so the
121 # configuration answering it has had no chance to be named yet.
122 profile = report_storage_configuration(load_storage_profile(profile_name, storage_config))
123 restored = []
124 for case_id in case_ids:
125 member = os.path.join(study_path, "cases", case_id)
126 state = read_storage_state(member) or {}
127 archive_id = state.get("archive_id")
128 if not archive_id:
129 raise StorageError(
130 f"{case_id} is cold but its marker names no archive; restore it by id."
131 )
132 restore_archive(profile, archive_id, destination=member, force=True)
133 restored.append(case_id)
134 return restored
135
136
137def build_storage_plan(target: dict, profile: dict, compression: str = None,
138 policy: str = None, keep_latest_checkpoint=None,
139 workers: int = None, retain=None, drop=None) -> dict:
140 """!
141 @brief Build the read-only plan consumed by protect and offload.
142 @param[in] target Value supplied through the `target` argument.
143 @param[in] profile Value supplied through the `profile` argument.
144 @param[in] compression Value supplied through the `compression` argument.
145 @param[in] policy Optional semantic retention policy override.
146 @param[in] keep_latest_checkpoint Optional newest-checkpoint retention override.
147 @param[in] workers Optional compression worker count override.
148 @param[in] retain Components to retain locally regardless of the policy preset.
149 @param[in] drop Components to prune locally regardless of the policy preset.
150 @return Result produced by this operation.
151 """
152 inventory = inspect_artifact(target)
153 selected_compression = _select_compression(compression, inventory["total_bytes"], profile)
154 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
155 retention = _resolve_offload_policy(profile, policy, keep_latest_checkpoint, retain, drop)
156 latest_step = max(inventory["checkpoint_steps"], default=None)
157 retained_bytes = sum(
158 entry["size"] for entry in inventory["entries"]
159 if entry["type"] != "directory" and _entry_retained_by_policy(entry, retention, latest_step)
160 )
161 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
162 if worker_count <= 0:
163 raise StorageError("Storage workers must be positive.")
164 compressed_range = _compression_size_range(inventory["total_bytes"], selected_compression)
165 return {
166 "inventory": inventory,
167 "compression": selected_compression,
168 "chunk_count": len(specs),
169 "workers": worker_count,
170 "offload_policy": retention,
171 "retained_local_bytes": retained_bytes,
172 "pruned_local_bytes": max(0, inventory["total_bytes"] - retained_bytes),
173 "estimated_stored_bytes": {"low": compressed_range[0], "high": compressed_range[1]},
174 "chunks": [
175 {
176 "component": spec["component"],
177 "file_count": len(spec["entries"]),
178 "uncompressed_bytes": spec["uncompressed_bytes"],
179 }
180 for spec in specs
181 ],
182 }
183
184
185def _render_plan(plan: dict) -> None:
186 """!
187 @brief Print a concise archive/offload plan.
188 @param[in] plan Value supplied through the `plan` argument.
189 """
190 inventory = plan["inventory"]
191 target = inventory["target"]
192 print(f"[INFO] Artifact type : {target['artifact_type']}")
193 print(f"[INFO] Artifact path : {target['root_path']}")
194 print(f"[INFO] Local size : {_human_bytes(inventory['total_bytes'])}")
195 print(f"[INFO] Files : {inventory['file_count']}")
196 print(f"[INFO] Checkpoints : {len(inventory['checkpoint_steps'])}")
197 print(f"[INFO] Compression : {plan['compression']}")
198 print(f"[INFO] CPU workers : {plan['workers']}")
199 print(f"[INFO] Archive chunks: {plan['chunk_count']}")
200 estimate = plan["estimated_stored_bytes"]
201 print(
202 f"[INFO] Remote estimate: {_human_bytes(estimate['low'])}–"
203 f"{_human_bytes(estimate['high'])} (content-dependent)"
204 )
205 policy = plan["offload_policy"]
206 print(f"[INFO] Offload policy: {policy['name']}")
207 # The preset name alone no longer says what is kept once --retain/--drop adjust it,
208 # so the resolved component set is reported rather than left to be inferred.
209 print(f"[INFO] Kept components: {', '.join(policy['retained_components'])}")
210 if policy.get("explicit_retain"):
211 print(f"[INFO] Retained by flag: {', '.join(policy['explicit_retain'])}")
212 if policy.get("explicit_drop"):
213 print(f"[INFO] Dropped by flag: {', '.join(policy['explicit_drop'])}")
214 print(f"[INFO] Retained local: {_human_bytes(plan['retained_local_bytes'])}")
215 print(f"[INFO] Pruned local : {_human_bytes(plan['pruned_local_bytes'])}")
216 if "checkpoints" in policy["retained_components"]:
217 print(f"[INFO] Kept checkpoint: every committed step ({len(inventory['checkpoint_steps'])})")
218 elif policy["keep_latest_checkpoint"]:
219 latest = max(inventory["checkpoint_steps"], default=None)
220 print(f"[INFO] Kept checkpoint: {latest if latest is not None else 'none available'}")
221 if inventory["external_paths"]:
222 print("[WARNING] External configured paths are recorded but are not followed automatically:")
223 for item in inventory["external_paths"]:
224 print(f" - {item['source']}: {item['path']}")
225 if inventory["dependencies"]:
226 print("[WARNING] External run dependencies:")
227 for item in inventory["dependencies"]:
228 print(f" - {item['kind']}: {item['path']}")
229 unclassified = [
230 entry for entry in inventory["entries"]
231 if entry["type"] != "directory" and entry["component"] == UNCLASSIFIED_COMPONENT
232 ]
233 if unclassified:
234 print(
235 f"[WARNING] {len(unclassified)} file(s) are not part of any known component. "
236 "They are archived, and retained locally by every policy, because storage "
237 "does not delete files whose purpose it cannot state:"
238 )
239 for entry in unclassified[:10]:
240 print(f" - {entry['path']}")
241 if len(unclassified) > 10:
242 print(f" - ... and {len(unclassified) - 10} more")
243
244
245def archive_artifact(target: dict, profile: dict, label: str = None, tags=None,
246 compression: str = None, prune_local: bool = False,
247 policy: str = None, keep_latest_checkpoint=None,
248 workers: int = None, notes: str = None, retain=None, drop=None) -> dict:
249 """!
250 @brief Package, upload, verify, register, and optionally prune one artifact.
251 @param[in] target Value supplied through the `target` argument.
252 @param[in] profile Value supplied through the `profile` argument.
253 @param[in] label Value supplied through the `label` argument.
254 @param[in] tags Value supplied through the `tags` argument.
255 @param[in] compression Value supplied through the `compression` argument.
256 @param[in] prune_local Value supplied through the `prune_local` argument.
257 @param[in] policy Optional semantic retention policy override.
258 @param[in] keep_latest_checkpoint Optional newest-checkpoint retention override.
259 @param[in] workers Optional compression worker count override.
260 @param[in] notes Optional free-text note recorded with the archive.
261 @param[in] retain Components to retain locally regardless of the policy preset.
262 @param[in] drop Components to prune locally regardless of the policy preset.
263 @return Result produced by this operation.
264 """
265 with storage_operation_lock(target["root_path"], "offload" if prune_local else "protect"):
266 plan = build_storage_plan(
267 target, profile, compression, policy=policy,
268 keep_latest_checkpoint=keep_latest_checkpoint, workers=workers,
269 retain=retain, drop=drop,
270 )
271 inventory = plan["inventory"]
272 _assert_archive_safe(inventory)
273 fingerprint = _inventory_fingerprint(inventory)
274 reusable = _find_reusable_archive(profile, target, fingerprint)
275 if reusable is not None:
276 # `protect` then `offload` is the documented workflow for "back it up now,
277 # free the space later". Re-packaging and re-uploading an unchanged artifact
278 # would store a second full copy of it, which for a campaign-sized run is
279 # the most expensive thing this command can do for no benefit.
280 print(
281 f"[INFO] Reusing verified archive {reusable['archive_id']}: "
282 f"{target['root_path']} is unchanged since it was archived."
283 )
284 return _register_existing_archive(
285 target, profile, reusable, inventory, plan, prune_local=prune_local,
286 label=label, tags=tags, notes=notes,
287 )
288 archive_id = uuid.uuid4().hex
289 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
290 staging_parent = profile.get("staging_directory")
291 if staging_parent:
292 staging_parent = os.path.abspath(os.path.expanduser(str(staging_parent)))
293 os.makedirs(staging_parent, exist_ok=True)
294 # As many chunks as `plan["workers"]` can be staged (packaged, not yet
295 # uploaded) at once; each is deleted right after its verified upload, so
296 # staging never needs archive-sized space, only room for the largest
297 # concurrent handful of uncompressed chunks.
298 concurrent_chunks = sorted(
299 (spec["uncompressed_bytes"] for spec in specs), reverse=True
300 )[:plan["workers"]]
301 _require_free_space(
302 staging_parent or tempfile.gettempdir(), sum(concurrent_chunks),
303 f"stage {target['root_path']}",
304 )
305 print(f"[INFO] Creating archive {archive_id} for {target['root_path']}")
306 with tempfile.TemporaryDirectory(prefix="picurv-storage-", dir=staging_parent) as staging:
307 def package_and_upload(index_spec):
308 """!
309 @brief Package and verify one component chunk.
310 @param[in] index_spec Tuple of chunk index and inventory specification.
311 @return Uploaded chunk manifest entry.
312 """
313 index, spec = index_spec
314 component = _safe_component_name(spec["component"])
315 filename = f"{index:05d}_{component}{_chunk_extension(plan['compression'])}"
316 local_chunk = os.path.join(staging, filename)
317 print(
318 f"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
319 f"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
320 )
321 compressor = _write_tar_chunk(
322 target["root_path"], spec, local_chunk, plan["compression"],
323 workers=plan["workers"],
324 )
325 try:
326 digest = _sha256_file(local_chunk)
327 # Content-addressed: an identical chunk anywhere on this remote is
328 # already stored, so a rerun after a failed upload skips what
329 # succeeded, and two archives of the same checkpoint share one copy.
330 if _remote_blob_present(profile, digest):
331 print(f"[INFO] Chunk {index + 1}/{len(specs)} already stored; skipping upload.")
332 verified = {
333 "sha256": digest, "stored_bytes": os.path.getsize(local_chunk),
334 }
335 else:
336 verified = _transport._upload_verified(local_chunk, _blob_remote(profile, digest))
337 return {
338 "name": filename,
339 "component": spec["component"],
340 "file_count": len(spec["entries"]),
341 "uncompressed_bytes": spec["uncompressed_bytes"],
342 "stored_bytes": verified["stored_bytes"],
343 "sha256": verified["sha256"],
344 "compressor": compressor,
345 "blob": True,
346 }
347 finally:
348 try:
349 os.remove(local_chunk)
350 except FileNotFoundError:
351 pass
352
353 # pigz/xz already use every requested CPU on one large chunk. The
354 # Python fallback instead gains parallelism across independent chunks.
355 native_parallel = (
356 plan["compression"] in _PARALLEL_GZIP_VALUES and shutil.which("pigz")
357 ) or (plan["compression"] == "maximum" and shutil.which("xz"))
358 task_workers = 1 if native_parallel else min(plan["workers"], max(1, len(specs)))
359 if task_workers == 1:
360 chunks = [package_and_upload(item) for item in enumerate(specs)]
361 else:
362 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
363 chunks = list(executor.map(package_and_upload, enumerate(specs)))
364 chunks.sort(key=lambda item: item["name"])
365
366 manifest = {
367 "storage_schema_version": STORAGE_SCHEMA_VERSION,
368 "archive_id": archive_id,
369 "created_at": _utc_now(),
370 "artifact_type": target["artifact_type"],
371 "run_id": target.get("run_id"),
372 "study_id": target.get("study_id"),
373 "case_id": target.get("case_id"),
374 "workspace_id": target.get("workspace_id"),
375 "label": label or os.path.basename(target["root_path"]),
376 "notes": notes,
377 "tags": _parse_tags(tags),
378 # What was actually solved, so `storage show` answers "which run was
379 # this?" without restoring anything.
380 "parameters": _capture_parameter_summary(target["root_path"]),
381 "original_path": target["original_path"],
382 "original_study_path": target.get("study_path"),
383 "profile": profile["name"],
384 "remote": profile["remote"],
385 "source_bytes": inventory["total_bytes"],
386 "source_file_count": inventory["file_count"],
387 "inventory_sha256": fingerprint,
388 "compression": plan["compression"],
389 "workers": plan["workers"],
390 "offload_policy": plan["offload_policy"],
391 "checkpoint_format_version": KNOWN_CHECKPOINT_VERSION,
392 "checkpoint_steps": inventory["checkpoint_steps"],
393 "chunks": chunks,
394 "config_sha256": _config_fingerprints(target["root_path"]),
395 "git": _git_provenance(target["root_path"]),
396 "external_paths": inventory["external_paths"],
397 "dependencies": inventory["dependencies"],
398 "study_context": _capture_study_context(target),
399 "run_assets": _capture_run_assets(target["root_path"]),
400 "workspace_assets": _capture_workspace_assets(target),
401 "capabilities": {
402 "restorable": True,
403 "continuable": bool(inventory["checkpoint_steps"]),
404 "reprocessable": bool(inventory["checkpoint_steps"]),
405 "exact_binary_reproduction": False,
406 },
407 }
408 manifest_path = os.path.join(staging, REMOTE_MANIFEST_FILENAME)
409 _atomic_write_json(manifest_path, manifest)
410 _transport._upload_verified(manifest_path, _object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
411 manifest_digest = _sha256_file(manifest_path)
412 complete_path = os.path.join(staging, REMOTE_COMPLETE_FILENAME)
413 with open(complete_path, "w", encoding="ascii") as stream:
414 stream.write(manifest_digest + "\n")
415 _transport._upload_verified(complete_path, _object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
416
417 state = {
418 "storage_schema_version": STORAGE_SCHEMA_VERSION,
419 "archive_id": archive_id,
420 "profile": profile["name"],
421 "remote": profile["remote"],
422 "label": manifest["label"],
423 "archived_at": manifest["created_at"],
424 "local_pruned": False,
425 "restored_components": [],
426 "retained_components": [],
427 }
428 _atomic_write_json(_state_path(target["root_path"]), state)
429 if prune_local:
430 _prune_archived_payload(target["root_path"], inventory, plan["offload_policy"])
431 state["local_pruned"] = True
432 state["pruned_at"] = _utc_now()
433 state["offload_policy"] = plan["offload_policy"]
434 latest_step = max(inventory["checkpoint_steps"], default=None)
435 retained = list(plan["offload_policy"]["retained_components"])
436 if plan["offload_policy"]["keep_latest_checkpoint"] and latest_step is not None:
437 retained.append(f"checkpoint:{latest_step}")
438 state["retained_components"] = retained
439 _atomic_write_json(_state_path(target["root_path"]), state)
440 print(
441 f"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
442 f"as archive {archive_id}."
443 )
444 return manifest
445
446
447def _register_existing_archive(target: dict, profile: dict, manifest: dict,
448 inventory: dict, plan: dict, *, prune_local: bool,
449 label: str = None, tags=None, notes: str = None) -> dict:
450 """!
451 @brief Point an artifact at an already-uploaded archive of its current content.
452
453 @details Used when nothing has changed since a previous `protect` or `offload`. The
454 remote object is immutable and is not rewritten; only this artifact's local
455 marker is updated, and a label or tags supplied now are recorded there so
456 the second invocation is not silently less descriptive than the first.
457 @param[in] target Local artifact target.
458 @param[in] profile Resolved storage profile.
459 @param[in] manifest Remote manifest being reused.
460 @param[in] inventory Current local inventory.
461 @param[in] plan Storage plan carrying the requested offload policy.
462 @param[in] prune_local Whether to prune the verified payload afterwards.
463 @param[in] label Optional label supplied to this invocation.
464 @param[in] tags Optional tags supplied to this invocation.
465 @param[in] notes Optional free-text note supplied to this invocation.
466 @return The reused remote manifest.
467 """
468 state = {
469 "storage_schema_version": STORAGE_SCHEMA_VERSION,
470 "archive_id": manifest["archive_id"],
471 "profile": profile["name"],
472 "remote": profile["remote"],
473 "label": label or manifest.get("label") or os.path.basename(target["root_path"]),
474 "archived_at": manifest.get("created_at"),
475 "reused_existing_archive": True,
476 "local_pruned": False,
477 "restored_components": [],
478 "retained_components": [],
479 }
480 parsed_tags = _parse_tags(tags)
481 if parsed_tags:
482 state["tags"] = parsed_tags
483 if notes:
484 state["notes"] = notes
485 _atomic_write_json(_state_path(target["root_path"]), state)
486 if prune_local:
487 _prune_archived_payload(target["root_path"], inventory, plan["offload_policy"])
488 state["local_pruned"] = True
489 state["pruned_at"] = _utc_now()
490 state["offload_policy"] = plan["offload_policy"]
491 latest_step = max(inventory["checkpoint_steps"], default=None)
492 retained = list(plan["offload_policy"]["retained_components"])
493 if plan["offload_policy"]["keep_latest_checkpoint"] and latest_step is not None:
494 retained.append(f"checkpoint:{latest_step}")
495 state["retained_components"] = retained
496 _atomic_write_json(_state_path(target["root_path"]), state)
497 print(
498 f"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
499 f"using existing archive {manifest['archive_id']}."
500 )
501 return manifest
502
503
504def _prune_archived_payload(root: str, inventory: dict, policy: dict) -> None:
505 """!
506 @brief Remove only verified heavy payload while retaining control-plane files.
507 @param[in] root Value supplied through the `root` argument.
508 @param[in] inventory Value supplied through the `inventory` argument.
509 @param[in] policy Normalized semantic retention policy.
510 """
511 latest_step = max(inventory["checkpoint_steps"], default=None)
512 for entry in sorted(inventory["entries"], key=lambda item: item["path"], reverse=True):
513 if entry["type"] == "directory" or _entry_retained_by_policy(entry, policy, latest_step):
514 continue
515 path = os.path.join(root, *entry["path"].split("/"))
516 try:
517 if os.path.islink(path) or os.path.isfile(path):
518 os.remove(path)
519 except FileNotFoundError:
520 pass
521 for entry in sorted(
522 (item for item in inventory["entries"] if item["type"] == "directory"),
523 key=lambda item: item["path"].count("/"), reverse=True,
524 ):
525 path = os.path.join(root, *entry["path"].split("/"))
526 try:
527 os.rmdir(path)
528 except OSError:
529 pass
530
531
532def _download_archive_components(profile: dict, manifest: dict, components: set,
533 destination: str, workers: int = None) -> None:
534 """!
535 @brief Download and safely merge selected semantic archive components.
536 @param[in] profile Active storage profile.
537 @param[in] manifest Verified remote archive manifest.
538 @param[in] components Semantic components to retrieve.
539 @param[in] destination Directory receiving merged content.
540 @param[in] workers Optional parallel worker count.
541 @return None.
542 """
543 chunks = [
544 chunk for chunk in manifest.get("chunks", [])
545 if str(chunk.get("component", "")) in components
546 ]
547 if not chunks:
548 raise StorageError(
549 f"Archive {manifest.get('archive_id')} has none of the requested components: "
550 + ", ".join(sorted(components))
551 )
552 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
553 if worker_count <= 0:
554 raise StorageError("Restore workers must be positive.")
555 os.makedirs(destination, exist_ok=True)
556 # Each chunk is extracted in full before `_merge_tree` copies it into `destination`,
557 # so both copies exist on disk at once.
558 extracted_bytes = sum(chunk.get("uncompressed_bytes", 0) for chunk in chunks)
559 _require_free_space(
560 destination, 2 * extracted_bytes, f"restore archive {manifest.get('archive_id')} components"
561 )
562 with tempfile.TemporaryDirectory(prefix="picurv-component-restore-") as staging:
563 def download_and_extract(index_chunk):
564 """!
565 @brief Download, verify, and extract one selected chunk.
566 @param[in] index_chunk Tuple of chunk index and manifest entry.
567 @return Chunk index and extraction directory.
568 """
569 index, chunk = index_chunk
570 chunk_root = os.path.join(staging, f"extract-{index:05d}")
571 os.makedirs(chunk_root)
572 local_chunk = os.path.join(staging, chunk["name"])
573 _transport._run_rclone([
574 "copyto",
575 _chunk_remote_path(profile, manifest["archive_id"], chunk),
576 local_chunk,
577 ])
578 actual = _sha256_file(local_chunk)
579 if actual != chunk.get("sha256"):
580 raise StorageError(
581 f"Downloaded chunk checksum mismatch: {chunk['name']} "
582 f"(expected {chunk.get('sha256')}, got {actual})."
583 )
584 _extract_chunk(local_chunk, chunk_root)
585 return index, chunk_root
586
587 task_workers = min(worker_count, len(chunks))
588 if task_workers == 1:
589 extracted = [download_and_extract(item) for item in enumerate(chunks)]
590 else:
591 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
592 extracted = list(executor.map(download_and_extract, enumerate(chunks)))
593 for _, chunk_root in sorted(extracted):
594 _merge_tree(chunk_root, destination)
595
596
597def restore_missing_workspace_assets(workspace_root: str, provider_spec_hashes) -> dict:
598 """!
599 @brief Recover immutable workspace assets from archived run-local input copies.
600 @param[in] workspace_root Initialized workspace receiving recovered objects.
601 @param[in] provider_spec_hashes Required provider-specification hashes.
602 @return Provider-specification hash to recovered asset-reference mapping.
603 """
604 requested = {str(value) for value in provider_spec_hashes if value}
605 if not requested:
606 return {}
607 workspace = Path(os.path.abspath(workspace_root))
608 (workspace / "assets").mkdir(parents=True, exist_ok=True)
609 profile = load_storage_profile(config_path=str(workspace / STORAGE_CONFIG_FILENAME))
610 matches = {}
611 for manifest in sorted(
612 list_remote_manifests(profile), key=lambda item: item.get("created_at", ""), reverse=True
613 ):
614 for reference in manifest.get("run_assets", []):
615 spec_hash = reference.get("provider_spec_sha256")
616 if spec_hash in requested and spec_hash not in matches:
617 matches[spec_hash] = (manifest, reference)
618 if requested <= set(matches):
619 break
620 if not matches:
621 return {}
622
623 restored = {}
624 by_archive = {}
625 for spec_hash, (manifest, reference) in matches.items():
626 group = by_archive.setdefault(manifest["archive_id"], {"manifest": manifest, "items": []})
627 group["items"].append((spec_hash, reference))
628 kind_directories = {
629 "grid": "grids",
630 "initial-condition": "initial_conditions",
631 "inlet-profiles": "inlet_profiles",
632 }
633 for group in by_archive.values():
634 with tempfile.TemporaryDirectory(prefix=".asset-restore-", dir=workspace / "assets") as temporary:
635 _download_archive_components(profile, group["manifest"], {"inputs"}, temporary)
636 for spec_hash, reference in group["items"]:
637 kind = reference.get("kind")
638 asset_id = reference.get("asset_id")
639 if kind not in kind_directories or not asset_id:
640 continue
641 object_relative = f"assets/objects/{kind_directories[kind]}/{asset_id}"
642 object_root = workspace / object_relative
643 if not object_root.is_dir():
644 object_root.parent.mkdir(parents=True, exist_ok=True)
645 object_staging = Path(tempfile.mkdtemp(
646 prefix=f".{asset_id[:12]}-", dir=object_root.parent
647 ))
648 try:
649 files = []
650 for item in reference.get("files", []):
651 source = Path(temporary, *item["archived_path"].split("/"))
652 if not source.is_file() or (
653 item.get("sha256") and _sha256_file(str(source)) != item["sha256"]
654 ):
655 raise StorageError(
656 f"Archived asset payload is missing or corrupt: {item['archived_path']}"
657 )
658 destination = object_staging / "payload" / item["path"]
659 destination.parent.mkdir(parents=True, exist_ok=True)
660 shutil.copy2(source, destination)
661 files.append({
662 "path": item["path"],
663 "bytes": destination.stat().st_size,
664 "sha256": _sha256_file(str(destination)),
665 })
666 _atomic_write_json(str(object_staging / "asset.json"), {
667 "schema_version": 1,
668 "asset_id": asset_id,
669 "kind": kind,
670 "provider": reference.get("provider"),
671 "provider_spec_sha256": spec_hash,
672 "recovered_from_archive": group["manifest"]["archive_id"],
673 "files": files,
674 })
675 try:
676 os.replace(object_staging, object_root)
677 except OSError as exc:
678 if exc.errno != errno.ENOTEMPTY and not object_root.is_dir():
679 raise
680 finally:
681 if object_staging.is_dir():
682 shutil.rmtree(object_staging, ignore_errors=True)
683 restored[spec_hash] = {
684 "asset_id": asset_id,
685 "kind": kind,
686 "provider": reference.get("provider"),
687 "provider_spec_sha256": spec_hash,
688 "object": object_relative,
689 "files": reference.get("files", []),
690 }
691 print(f"[INFO] Restored shared asset {kind}: {asset_id}")
692 return restored
693
694
695def restore_archive(profile: dict, archive_id: str, destination: str = None,
696 checkpoints=None, force: bool = False,
697 workers: int = None, components=None) -> dict:
698 """!
699 @brief Download, verify, extract, and materialize an archive or selected checkpoints.
700 @param[in] profile Value supplied through the `profile` argument.
701 @param[in] archive_id Value supplied through the `archive_id` argument.
702 @param[in] destination Value supplied through the `destination` argument.
703 @param[in] checkpoints Value supplied through the `checkpoints` argument.
704 @param[in] force Value supplied through the `force` argument.
705 @param[in] workers Optional parallel download and extraction worker count.
706 @param[in] components Optional semantic components to restore.
707 @return Result produced by this operation.
708 """
709 manifest = _load_remote_manifest(profile, archive_id)
710 original = os.path.abspath(manifest["original_path"])
711 destination_abs = os.path.abspath(destination or original)
712 selected_steps = {int(step) for step in (checkpoints or [])}
713 selected_components = {str(component) for component in (components or [])}
714 chunks = []
715 for chunk in manifest.get("chunks", []):
716 component = str(chunk.get("component", ""))
717 if selected_steps or selected_components:
718 if component in ALWAYS_RESTORED_COMPONENTS:
719 chunks.append(chunk)
720 elif component.startswith("checkpoint:") and int(component.split(":", 1)[1]) in selected_steps:
721 chunks.append(chunk)
722 elif component in selected_components:
723 chunks.append(chunk)
724 else:
725 chunks.append(chunk)
726 if selected_steps:
727 available = set(manifest.get("checkpoint_steps", []))
728 missing = selected_steps - available
729 if missing:
730 raise StorageError(f"Archive {archive_id} does not contain checkpoint step(s): {sorted(missing)}")
731 existing_state = read_storage_state(destination_abs) if os.path.isdir(destination_abs) else None
732 if os.path.exists(destination_abs) and not force:
733 if not existing_state or existing_state.get("archive_id") != archive_id:
734 raise StorageError(
735 f"Restore destination already exists and is not the matching cold artifact: {destination_abs}. "
736 "Choose --to or use --force after verifying the destination."
737 )
738 parent = os.path.dirname(destination_abs)
739 os.makedirs(parent, exist_ok=True)
740 # Every selected chunk is extracted in full before `_merge_tree` copies it into
741 # `materialized`, so both copies exist on disk at once ahead of the final move.
742 extracted_bytes = sum(chunk.get("uncompressed_bytes", 0) for chunk in chunks)
743 _require_free_space(parent, 2 * extracted_bytes, f"restore archive {archive_id}")
744 temporary = tempfile.mkdtemp(prefix=f".picurv-restore-{archive_id[:8]}-", dir=parent)
745 materialized = os.path.join(temporary, "materialized")
746 os.makedirs(materialized)
747 try:
748 newly_restored = (
749 [f"checkpoint:{step}" for step in sorted(selected_steps)]
750 + sorted(selected_components)
751 )
752 prior_restored = (existing_state or {}).get("restored_components") or []
753 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
754 if worker_count <= 0:
755 raise StorageError("Restore workers must be positive.")
756
757 def download_and_extract(index_chunk):
758 """!
759 @brief Download, verify, and extract one archive chunk.
760 @param[in] index_chunk Tuple of chunk index and manifest entry.
761 @return Chunk index and extraction directory.
762 """
763 index, chunk = index_chunk
764 chunk_root = os.path.join(temporary, f"extract-{index:05d}")
765 os.makedirs(chunk_root)
766 local_chunk = os.path.join(temporary, chunk["name"])
767 print(f"[INFO] Restoring chunk {index + 1}/{len(chunks)}: {chunk['component']}")
768 _transport._run_rclone([
769 "copyto", _chunk_remote_path(profile, archive_id, chunk), local_chunk,
770 ])
771 actual = _sha256_file(local_chunk)
772 if actual != chunk.get("sha256"):
773 raise StorageError(
774 f"Downloaded chunk checksum mismatch: {chunk['name']} "
775 f"(expected {chunk.get('sha256')}, got {actual})."
776 )
777 _extract_chunk(local_chunk, chunk_root)
778 os.remove(local_chunk)
779 return index, chunk_root
780
781 task_workers = min(worker_count, max(1, len(chunks)))
782 if task_workers == 1:
783 extracted = [download_and_extract(item) for item in enumerate(chunks)]
784 else:
785 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
786 extracted = list(executor.map(download_and_extract, enumerate(chunks)))
787 for _, chunk_root in sorted(extracted):
788 _merge_tree(chunk_root, materialized)
789
790 if os.path.isdir(destination_abs):
791 _merge_tree(materialized, destination_abs)
792 else:
793 os.replace(materialized, destination_abs)
794 _restore_study_context(manifest, destination_abs)
795
796 replacements = [(original, destination_abs)]
797 original_study = manifest.get("original_study_path")
798 if original_study and manifest.get("artifact_type") == "study-case":
799 replacements.append((os.path.abspath(original_study), str(Path(destination_abs).parent.parent)))
800 rebased = _rebase_restored_text_paths(destination_abs, replacements)
801 state = {
802 "storage_schema_version": STORAGE_SCHEMA_VERSION,
803 "archive_id": archive_id,
804 "profile": profile["name"],
805 "remote": profile["remote"],
806 "label": manifest.get("label"),
807 "archived_at": manifest.get("created_at"),
808 "restored_at": _utc_now(),
809 "local_pruned": bool(selected_steps or selected_components),
810 "restored_components": sorted(set(prior_restored) | set(newly_restored)),
811 "retained_components": (existing_state or {}).get("retained_components") or [],
812 "relocated_from": original if original != destination_abs else None,
813 "rebased_files": len(rebased),
814 }
815 _atomic_write_json(_state_path(destination_abs), state)
816 except Exception:
817 if os.path.isdir(temporary):
818 shutil.rmtree(temporary, ignore_errors=True)
819 raise
820 if os.path.isdir(temporary):
821 shutil.rmtree(temporary, ignore_errors=True)
822 print(f"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
823 if rebased:
824 print(f"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
825 return manifest
826
827
828def _expand_checkpoint_selection(explicit, ranges) -> list:
829 """!
830 @brief Combine repeated `--checkpoint` values with `START:END[:STRIDE]` ranges.
831 @param[in] explicit Individually named steps, or None.
832 @param[in] ranges Range expressions, or None.
833 @return Sorted unique step list, or None when nothing was selected.
834 """
835 selected = set(explicit or ())
836 for expression in ranges or ():
837 parts = str(expression).split(":")
838 if len(parts) not in (2, 3):
839 raise StorageError(
840 f"--checkpoints expects START:END or START:END:STRIDE, got {expression!r}."
841 )
842 try:
843 numbers = [int(part) for part in parts]
844 except ValueError:
845 raise StorageError(
846 f"--checkpoints expects integer steps, got {expression!r}."
847 ) from None
848 start, end = numbers[0], numbers[1]
849 stride = numbers[2] if len(numbers) == 3 else 1
850 if stride <= 0:
851 raise StorageError(f"--checkpoints stride must be positive, got {expression!r}.")
852 if end < start:
853 raise StorageError(f"--checkpoints end precedes start in {expression!r}.")
854 selected.update(range(start, end + 1, stride))
855 return sorted(selected) or None
856
857
858def _resolve_archive_id_from_args(args) -> str:
859 """!
860 @brief Resolve an explicit archive ID or a local artifact marker.
861 @param[in] args Value supplied through the `args` argument.
862 @return Result produced by this operation.
863 """
864 archive_id = getattr(args, "archive_id", None)
865 if archive_id:
866 return archive_id
867 workspace_id = getattr(args, "workspace_id", None)
868 if workspace_id:
869 return resolve_workspace_archive_id(
870 load_storage_profile(
871 getattr(args, "profile", None), getattr(args, "storage_config", None)
872 ),
873 workspace_id,
874 )
875 targets = resolve_local_storage_targets(
876 getattr(args, "run_dir", None), getattr(args, "study_dir", None), getattr(args, "case_ids", None)
877 )
878 if len(targets) != 1:
879 raise StorageError("Restore/verify by local marker requires exactly one target.")
880 state = read_storage_state(targets[0]["root_path"])
881 if not state or not state.get("archive_id"):
882 raise StorageError(f"No storage marker with an archive ID exists under {targets[0]['root_path']}.")
883 return state["archive_id"]
884
885
886def _render_status(inventory: dict) -> None:
887 """!
888 @brief Render one artifact status row and safety details.
889 @param[in] inventory Value supplied through the `inventory` argument.
890 """
891 target = inventory["target"]
892 storage = inventory["storage"]
893 activity = "BUSY" if inventory["active_locks"] or inventory["slurm"]["active"] else storage["state"]
894 identity = (
895 target.get("case_id") or target.get("run_id") or target.get("study_id")
896 or target.get("workspace_id") or os.path.basename(target["root_path"])
897 )
898 print(
899 f"{identity:<36} {target['artifact_type']:<12} {activity:<10} "
900 f"{_human_bytes(inventory['total_bytes']):>12} {storage.get('label') or ''}"
901 )
902
903
904def report_storage_configuration(profile: dict) -> dict:
905 """!
906 @brief State which configuration answered, and warn when it came from outside.
907
908 @details Every command below acts on the remote this file names, and `offload`
909 prunes local payload once that remote has verified the upload. Discovery
910 searches upward past the workspace boundary, so the file that answered is
911 not always the one the user thinks they are standing in; naming it here is
912 what keeps an inherited configuration a choice rather than an accident.
913 @param[in] profile Resolved storage profile, carrying its `config_path`.
914 @return The profile, unchanged, so callers can wrap the load.
915 """
916 config_path = profile.get("config_path")
917 origin = storage_config_origin(config_path)
918 print(f"[INFO] Storage config : {config_path}")
919 print(f"[INFO] Storage profile: {profile['name']} -> {profile['remote']}")
920 if origin == "shared":
921 print(
922 f"[WARNING] That configuration is outside this workspace; it is shared with "
923 f"everything below {os.path.dirname(os.path.abspath(config_path))}. Pass "
924 "--storage-config to choose another, or run 'picurv storage setup' here to "
925 "give this workspace its own.",
926 file=sys.stderr,
927 )
928 return profile
929
930
932 """!
933 @brief Load the storage profile a command will act on, and report its origin.
934 @param[in] args Parsed storage command arguments.
935 @return Resolved storage profile.
936 """
938 load_storage_profile(getattr(args, "profile", None),
939 getattr(args, "storage_config", None))
940 )
941
942
943def storage_setup_workflow(args) -> None:
944 """!
945 @brief Create or update a non-secret workspace storage profile.
946 @param[in] args Value supplied through the `args` argument.
947 """
948 explicit_config = getattr(args, "storage_config", None)
949 config_path = resolve_storage_config_path(explicit_config, require=False)
950 workspace_root = storage_workspace_root()
951 # Discovery walks past the workspace boundary, which is right for reading a shared
952 # configuration and wrong for writing one: `setup` inside a fresh workspace would
953 # otherwise rewrite the remote of the campaign directory above it, silently
954 # re-pointing every other workspace under it. Configure the workspace being stood
955 # in; editing a shared file stays available, but has to be asked for.
956 if not explicit_config and workspace_root and storage_config_origin(config_path, workspace_root) == "shared":
957 inherited = config_path
958 config_path = os.path.join(workspace_root, STORAGE_CONFIG_FILENAME)
959 print(
960 f"[INFO] A shared configuration exists at {inherited}, but it belongs to a "
961 "directory above this workspace."
962 )
963 print(
964 f" Writing this workspace's own configuration instead. To edit the "
965 f"shared one, pass --storage-config {inherited}."
966 )
967 payload = {}
968 if os.path.isfile(config_path):
969 with open(config_path, "r", encoding="utf-8") as stream:
970 payload = yaml.safe_load(stream) or {}
971 profiles = payload.setdefault("profiles", {})
972 profile_name = args.profile or DEFAULT_PROFILE_NAME
973 profile = {
974 "remote": args.remote.rstrip("/"),
975 "compression": args.compression,
976 "chunk_size_gib": args.chunk_size_gib,
977 "workers": getattr(args, "workers", DEFAULT_STORAGE_WORKERS),
978 "offload_policy": getattr(args, "offload_policy", "metadata-only"),
979 "keep_latest_checkpoint": bool(getattr(args, "keep_latest_checkpoint", False)),
980 }
981 if args.staging_directory:
982 profile["staging_directory"] = os.path.abspath(os.path.expanduser(args.staging_directory))
983 payload["default_profile"] = profile_name
984 profiles[profile_name] = profile
985 print(f"[INFO] Storage config : {config_path}")
986 print(f"[INFO] Profile : {profile_name}")
987 print(f"[INFO] Remote : {profile['remote']}")
988 if args.dry_run:
989 print("[INFO] Dry-run only. No configuration or remote directories were changed.")
990 return
991 _transport._run_rclone(["mkdir", _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)])
992 os.makedirs(os.path.dirname(config_path), exist_ok=True)
993 temporary = f"{config_path}.tmp.{os.getpid()}"
994 with open(temporary, "w", encoding="utf-8") as stream:
995 yaml.safe_dump(payload, stream, sort_keys=False)
996 os.replace(temporary, config_path)
997 print("[SUCCESS] Storage profile configured and remote access verified.")
998
999
1000def storage_status_workflow(args) -> None:
1001 """!
1002 @brief Print local storage and lifecycle status for selected artifacts.
1003 @param[in] args Value supplied through the `args` argument.
1004 """
1005 if args.study_dir and not args.case_ids:
1006 study_root = os.path.abspath(args.study_dir)
1007 if getattr(args, "completed", False):
1008 case_ids = _select_completed_case_ids(study_root)
1009 else:
1010 case_ids = [
1011 path.name for path in sorted((Path(study_root) / "cases").glob("case_*")) if path.is_dir()
1012 ]
1013 targets = resolve_local_storage_targets(None, study_root, case_ids) if case_ids else resolve_local_storage_targets(None, study_root)
1014 else:
1015 targets = resolve_local_storage_targets(
1016 args.run_dir, args.study_dir, args.case_ids,
1017 workspace=getattr(args, "workspace", None),
1018 include_inputs=getattr(args, "include_inputs", False),
1019 completed=getattr(args, "completed", False),
1020 )
1021 inventories = [inspect_artifact(target) for target in targets]
1022 if args.output_format == "json":
1023 serializable = []
1024 for item in inventories:
1025 copy_item = dict(item)
1026 copy_item.pop("entries", None)
1027 serializable.append(copy_item)
1028 print(json.dumps(serializable, indent=2, sort_keys=True))
1029 return
1030 print(f"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1031 for inventory in inventories:
1032 _render_status(inventory)
1033
1034
1035def storage_plan_workflow(args) -> None:
1036 """!
1037 @brief Render a read-only packaging and safety plan.
1038 @param[in] args Value supplied through the `args` argument.
1039 """
1040 profile = load_reported_storage_profile(args)
1041 targets = resolve_local_storage_targets(
1042 args.run_dir, args.study_dir, args.case_ids,
1043 workspace=getattr(args, "workspace", None),
1044 include_inputs=getattr(args, "include_inputs", False),
1045 completed=getattr(args, "completed", False),
1046 )
1047 for index, target in enumerate(targets):
1048 if index:
1049 print()
1050 plan = build_storage_plan(
1051 target, profile, args.compression,
1052 policy=getattr(args, "policy", None),
1053 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1054 workers=getattr(args, "workers", None),
1055 retain=getattr(args, "retain", None), drop=getattr(args, "drop", None),
1056 )
1057 _render_plan(plan)
1058 _assert_archive_safe(plan["inventory"])
1059
1060
1061def storage_archive_workflow(args, prune_local: bool) -> None:
1062 """!
1063 @brief Execute protect or offload for one or more explicit local targets.
1064 @param[in] args Value supplied through the `args` argument.
1065 @param[in] prune_local Value supplied through the `prune_local` argument.
1066 """
1067 profile = load_reported_storage_profile(args)
1068 targets = resolve_local_storage_targets(
1069 args.run_dir, args.study_dir, args.case_ids,
1070 workspace=getattr(args, "workspace", None),
1071 include_inputs=getattr(args, "include_inputs", False),
1072 completed=getattr(args, "completed", False),
1073 )
1074 for target in targets:
1075 if args.dry_run:
1076 _render_plan(build_storage_plan(
1077 target, profile, args.compression,
1078 policy=getattr(args, "policy", None),
1079 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1080 workers=getattr(args, "workers", None),
1081 retain=getattr(args, "retain", None), drop=getattr(args, "drop", None),
1082 ))
1083 print("[INFO] Dry-run only. No files were packaged, uploaded, or pruned.")
1084 continue
1086 target,
1087 profile,
1088 label=args.label,
1089 tags=args.tags,
1090 compression=args.compression,
1091 prune_local=prune_local,
1092 policy=getattr(args, "policy", None),
1093 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1094 workers=getattr(args, "workers", None),
1095 notes=getattr(args, "notes", None),
1096 retain=getattr(args, "retain", None),
1097 drop=getattr(args, "drop", None),
1098 )
1099
1100
1102 """!
1103 @brief Restore a remote archive by globally unique ID or local marker.
1104 @param[in] args Value supplied through the `args` argument.
1105 """
1106 profile = load_reported_storage_profile(args)
1107 archive_id = _resolve_archive_id_from_args(args)
1109 profile,
1110 archive_id,
1111 destination=args.destination,
1112 checkpoints=_expand_checkpoint_selection(
1113 args.checkpoints, getattr(args, "checkpoint_ranges", None)
1114 ),
1115 force=args.force,
1116 workers=getattr(args, "workers", None),
1117 components=getattr(args, "components", None),
1118 )
1119
1120
1121def storage_prune_workflow(args) -> None:
1122 """!
1123 @brief Remove local asset objects nothing local needs and storage has verified.
1124 @param[in] args Value supplied through the `args` argument.
1125 """
1126 profile = load_reported_storage_profile(args)
1127 workspace_root = os.path.abspath(args.workspace) if args.workspace else _find_upwards(
1128 os.getcwd(), WORKSPACE_CONFIG_FILENAME
1129 )
1130 if workspace_root and os.path.isfile(workspace_root):
1131 workspace_root = os.path.dirname(workspace_root)
1132 if not workspace_root:
1133 raise StorageError(
1134 "No initialized workspace found. Pass --workspace, or run inside one."
1135 )
1136 decisions = prune_unused_workspace_assets(workspace_root, profile, dry_run=args.dry_run)
1137 if not decisions:
1138 print("[INFO] The workspace asset store holds no published objects.")
1139 return
1140 for decision in decisions:
1141 print(
1142 f"{decision['asset_id'][:16]} {decision['kind']}\n"
1143 f" referenced by remote runs {decision['cold_runs']}\n"
1144 f" referenced by active local runs {decision['active_local_runs']}\n"
1145 f" remote protection {decision['remote_protection']}\n"
1146 f" local removal {decision['local_removal']}"
1147 )
1148 removed = [item for item in decisions if item.get("removed")]
1149 if args.dry_run:
1150 safe = [item for item in decisions if item["local_removal"] == "safe"]
1151 print(f"[INFO] Dry-run only. {len(safe)} object(s) would be removed.")
1152 else:
1153 print(f"[SUCCESS] Removed {len(removed)} local asset object(s).")
1154
1155
1156def storage_verify_workflow(args) -> None:
1157 """!
1158 @brief Verify all remote chunks for one archive.
1159 @param[in] args Value supplied through the `args` argument.
1160 """
1161 profile = load_reported_storage_profile(args)
1162 archive_id = _resolve_archive_id_from_args(args)
1163 manifest = verify_remote_archive(profile, archive_id)
1164 print(
1165 f"[SUCCESS] Archive {archive_id} is complete; verified {len(manifest.get('chunks', []))} chunk(s)."
1166 )
1167
1168
1169def storage_list_workflow(args) -> None:
1170 """!
1171 @brief Search the remote manifest catalog without local artifact state.
1172 @param[in] args Value supplied through the `args` argument.
1173 """
1174 profile = load_reported_storage_profile(args)
1175 manifests = list_remote_manifests(profile)
1176 workspace_label = getattr(args, "workspace_label", None)
1177 if workspace_label:
1178 manifests = [
1179 item for item in manifests
1180 if str(item.get("workspace_id") or "") == workspace_label
1181 ]
1182 query = str(args.search or "").lower()
1183 if query:
1184 manifests = [
1185 item for item in manifests
1186 if query in " ".join(
1187 str(item.get(key, "")) for key in (
1188 "archive_id", "label", "notes", "run_id", "study_id", "case_id",
1189 "workspace_id", "tags",
1190 )
1191 ).lower()
1192 ]
1193 if args.output_format == "json":
1194 print(json.dumps(manifests, indent=2, sort_keys=True))
1195 return
1196 print(f"{'ARCHIVE ID':<34} {'TYPE':<12} {'IDENTITY':<32} LABEL")
1197 for item in manifests:
1198 identity = (
1199 item.get("case_id") or item.get("run_id") or item.get("study_id")
1200 or item.get("workspace_id") or "-"
1201 )
1202 print(f"{item['archive_id']:<34} {item.get('artifact_type', '-'):<12} {identity:<32} {item.get('label', '')}")
1203
1204
1205def storage_show_workflow(args) -> None:
1206 """!
1207 @brief Show one complete remote archive manifest.
1208 @param[in] args Value supplied through the `args` argument.
1209 """
1210 profile = load_reported_storage_profile(args)
1211 manifest = _load_remote_manifest(profile, args.archive_id)
1212 print(json.dumps(manifest, indent=2, sort_keys=True))
1213
1214
1215def storage_workflow(args) -> None:
1216 """!
1217 @brief Dispatch nested storage actions using existing PICurv workflow conventions.
1218 @param[in] args Value supplied through the `args` argument.
1219 """
1220 try:
1221 action = args.storage_action
1222 if action == "setup":
1224 elif action == "status":
1226 elif action == "plan":
1228 elif action == "protect":
1229 storage_archive_workflow(args, prune_local=False)
1230 elif action == "offload":
1231 storage_archive_workflow(args, prune_local=True)
1232 elif action == "restore":
1234 elif action == "prune":
1236 elif action == "verify":
1238 elif action == "list":
1240 elif action == "show":
1242 else:
1243 raise StorageError(f"Unsupported storage action: {action}")
1244 except StorageError as exc:
1245 print(f"[FATAL] {exc}", file=sys.stderr)
1246 raise SystemExit(1)
1247
1248
1249def add_storage_parser(subparsers) -> argparse.ArgumentParser:
1250 """!
1251 @brief Attach the nested storage command parser to PICurv's top-level parser.
1252 @param[in] subparsers Value supplied through the `subparsers` argument.
1253 @return Result produced by this operation.
1254 """
1255 parser = subparsers.add_parser(
1256 "storage",
1257 help="Protect, offload, inspect, verify, and restore run/study artifacts.",
1258 formatter_class=argparse.RawTextHelpFormatter,
1259 description=(
1260 "Manage PICurv run and study data through a configured rclone remote.\n"
1261 "Remote archives are checksum-verified before local payload can be pruned.\n\n"
1262 "Examples:\n"
1263 " picurv storage setup --remote labstore:picurv-data\n"
1264 " picurv storage status --run-dir runs/my_run\n"
1265 " picurv storage protect --run-dir runs/my_run --label 'baseline'\n"
1266 " picurv storage offload --study-dir studies/my_study --case-id case_0003\n"
1267 " picurv storage list --search '64-grid'\n"
1268 " picurv storage restore --archive-id <id>"
1269 ),
1270 epilog="Use `picurv storage <action> --help` for action-specific controls.",
1271 )
1272 actions = parser.add_subparsers(dest="storage_action", required=True, help="Storage action")
1273
1274 setup = actions.add_parser("setup", help="Configure a non-secret rclone storage profile.")
1275 setup.add_argument("--remote", required=True, help="Rclone remote and base path, such as labstore:picurv-data.")
1276 setup.add_argument("--profile", default=DEFAULT_PROFILE_NAME, help="Profile name (default: archive).")
1277 setup.add_argument("--storage-config", help=f"Storage YAML path (default: ./{STORAGE_CONFIG_FILENAME}).")
1278 setup.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES), default="auto")
1279 setup.add_argument("--chunk-size-gib", type=float, default=DEFAULT_CHUNK_SIZE_GIB)
1280 setup.add_argument("--workers", type=int, default=DEFAULT_STORAGE_WORKERS,
1281 help="CPU workers for compression and restoration.")
1282 setup.add_argument("--offload-policy", choices=list(STORAGE_OFFLOAD_POLICIES), default="metadata-only")
1283 setup.add_argument("--keep-latest-checkpoint", action="store_true",
1284 help="Retain the newest committed checkpoint after offload.")
1285 setup.add_argument("--staging-directory", help="Optional local directory for one archive chunk at a time.")
1286 setup.add_argument("--dry-run", action="store_true")
1287
1288 def add_profile_options(action_parser):
1289 """!
1290 @brief Attach shared storage-profile selectors to one action parser.
1291 @param[in] action_parser Value supplied through the `action_parser` argument.
1292 """
1293 action_parser.add_argument("--profile", help="Configured storage profile name.")
1294 action_parser.add_argument("--storage-config", help="Explicit storage YAML path.")
1295
1296 def add_local_target(action_parser, require=True, allow_workspace=True):
1297 """!
1298 @brief Attach the standard run/study/workspace target selectors to one parser.
1299 @param[in] action_parser Value supplied through the `action_parser` argument.
1300 @param[in] require Value supplied through the `require` argument.
1301 @param[in] allow_workspace Whether a whole workspace is a valid target here.
1302 """
1303 group = action_parser.add_mutually_exclusive_group(required=require)
1304 group.add_argument("--run-dir", help="Standalone run directory.")
1305 group.add_argument("--study-dir", help="Sweep study directory.")
1306 if allow_workspace:
1307 group.add_argument(
1308 "--workspace",
1309 help="Workspace root: its configuration, catalog, and assets, not its "
1310 "runs and studies, which are their own artifacts.",
1311 )
1312 action_parser.add_argument(
1313 "--case-id", dest="case_ids", action="append",
1314 help="One numbered study member, such as case_0003; repeat to select several.",
1315 )
1316 action_parser.add_argument(
1317 "--completed", action="store_true",
1318 help="With --study-dir, select every finished member and skip the rest.",
1319 )
1320
1321 def add_retention_options(parser):
1322 """!
1323 @brief Add the per-component local-retention overrides to one action parser.
1324 @param[in] parser Action parser being configured.
1325 @return None.
1326 """
1327 parser.add_argument(
1328 "--retain", action="append", metavar="COMPONENT",
1329 help="Keep this component local regardless of --policy; repeatable and\n"
1330 "comma-separated. One of: " + ", ".join(STORAGE_RETENTION_COMPONENTS) + ".",
1331 )
1332 parser.add_argument(
1333 "--drop", action="append", metavar="COMPONENT",
1334 help="Prune this component locally regardless of --policy; same names as --retain.",
1335 )
1336
1337 status = actions.add_parser("status", help="Show local, protected, cold, and busy artifact state.")
1338 add_local_target(status)
1339 status.add_argument("--format", dest="output_format", choices=list(CLI_OUTPUT_FORMATS), default="text")
1340
1341 plan = actions.add_parser("plan", help="Show packaging, dependencies, and safety checks without writing.")
1342 add_local_target(plan)
1343 add_profile_options(plan)
1344 plan.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES))
1345 plan.add_argument("--policy", choices=list(STORAGE_OFFLOAD_POLICIES))
1346 add_retention_options(plan)
1347 plan.add_argument("--workers", type=int)
1348 plan_checkpoint = plan.add_mutually_exclusive_group()
1349 plan_checkpoint.add_argument("--keep-latest-checkpoint", dest="keep_latest_checkpoint", action="store_true")
1350 plan_checkpoint.add_argument("--drop-all-checkpoints", dest="keep_latest_checkpoint", action="store_false")
1351 plan.set_defaults(keep_latest_checkpoint=None)
1352
1353 for name, help_text in (
1354 ("protect", "Upload and verify an archive while retaining all local files."),
1355 ("offload", "Upload and verify an archive, then prune heavy local payload."),
1356 ):
1357 action_parser = actions.add_parser(name, help=help_text)
1358 add_local_target(action_parser)
1359 add_profile_options(action_parser)
1360 action_parser.add_argument(
1361 "--include-inputs", action="store_true",
1362 help="With --workspace, also archive user-supplied files under inputs/.",
1363 )
1364 action_parser.add_argument("--label", help="Human-readable searchable label.")
1365 action_parser.add_argument(
1366 "--notes", help="Free-text note recorded with the archive and shown by `show`."
1367 )
1368 action_parser.add_argument("--tag", dest="tags", action="append", help="Repeatable KEY=VALUE catalog tag.")
1369 action_parser.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES))
1370 action_parser.add_argument("--policy", choices=list(STORAGE_OFFLOAD_POLICIES))
1371 add_retention_options(action_parser)
1372 action_parser.add_argument("--workers", type=int)
1373 checkpoint_group = action_parser.add_mutually_exclusive_group()
1374 checkpoint_group.add_argument("--keep-latest-checkpoint", dest="keep_latest_checkpoint", action="store_true")
1375 checkpoint_group.add_argument("--drop-all-checkpoints", dest="keep_latest_checkpoint", action="store_false")
1376 action_parser.set_defaults(keep_latest_checkpoint=None)
1377 action_parser.add_argument("--dry-run", action="store_true")
1378
1379 restore = actions.add_parser("restore", help="Restore a complete archive or selected checkpoints.")
1380 restore_source = restore.add_mutually_exclusive_group(required=True)
1381 restore_source.add_argument("--archive-id", help="Globally unique remote archive ID.")
1382 restore_source.add_argument(
1383 "--workspace-id", help="Restore a workspace archive by its recorded workspace identity."
1384 )
1385 restore_source.add_argument("--run-dir", help="Cold run containing a local storage marker.")
1386 restore_source.add_argument("--study-dir", help="Cold study containing a local storage marker.")
1387 restore.add_argument("--case-id", dest="case_ids", action="append")
1388 add_profile_options(restore)
1389 restore.add_argument("--to", dest="destination", help="Optional alternate restore destination.")
1390 restore.add_argument(
1391 "--checkpoint", dest="checkpoints", action="append", type=int,
1392 help="One committed step; repeat to select several.",
1393 )
1394 restore.add_argument(
1395 "--checkpoints", dest="checkpoint_ranges", action="append",
1396 help="An inclusive step range as START:END or START:END:STRIDE; repeatable.",
1397 )
1398 restore.add_argument(
1399 "--component", dest="components", action="append",
1400 choices=STORAGE_RESTORE_COMPONENTS,
1401 help="Restore one semantic component; repeat as needed.",
1402 )
1403 restore.add_argument("--force", action="store_true", help="Allow merge into a non-matching existing destination.")
1404 restore.add_argument("--workers", type=int, help="Parallel download/extraction workers.")
1405
1406 prune = actions.add_parser(
1407 "prune", help="Remove verified local asset objects that nothing local still needs."
1408 )
1409 prune.add_argument("--workspace", help="Workspace root; defaults to discovery from the cwd.")
1410 prune.add_argument(
1411 "--assets", action="store_true", required=True,
1412 help="Select the workspace asset store. Required: prune removes nothing else.",
1413 )
1414 prune.add_argument(
1415 "--unused-locally", action="store_true", required=True,
1416 help="Confirm that only objects with no active local run are removed.",
1417 )
1418 prune.add_argument("--dry-run", action="store_true", help="Report the decision only.")
1419 add_profile_options(prune)
1420
1421 verify = actions.add_parser("verify", help="Verify a remote archive completion marker and chunk checksums.")
1422 verify_source = verify.add_mutually_exclusive_group(required=True)
1423 verify_source.add_argument("--archive-id")
1424 verify_source.add_argument("--run-dir")
1425 verify_source.add_argument("--study-dir")
1426 verify.add_argument("--case-id", dest="case_ids", action="append")
1427 add_profile_options(verify)
1428
1429 list_parser = actions.add_parser("list", help="List/search remote archives without local directories.")
1430 add_profile_options(list_parser)
1431 list_parser.add_argument("--search", help="Case-insensitive search across IDs, labels, identities, and tags.")
1432 list_parser.add_argument(
1433 "--workspace-label", help="Show only archives belonging to this workspace identity."
1434 )
1435 list_parser.add_argument("--format", dest="output_format", choices=list(CLI_OUTPUT_FORMATS), default="text")
1436
1437 show = actions.add_parser("show", help="Print the complete manifest for one archive.")
1438 show.add_argument("--archive-id", required=True)
1439 add_profile_options(show)
1440 return parser
User-facing storage workflow failure.
Definition models.py:139
None storage_list_workflow(args)
Search the remote manifest catalog without local artifact state.
None storage_status_workflow(args)
Print local storage and lifecycle status for selected artifacts.
dict restore_missing_workspace_assets(str workspace_root, provider_spec_hashes)
Recover immutable workspace assets from archived run-local input copies.
None storage_archive_workflow(args, bool prune_local)
Execute protect or offload for one or more explicit local targets.
dict report_storage_configuration(dict profile)
State which configuration answered, and warn when it came from outside.
None storage_prune_workflow(args)
Remove local asset objects nothing local needs and storage has verified.
dict restore_archive(dict profile, str archive_id, str destination=None, checkpoints=None, bool force=False, int workers=None, components=None)
Download, verify, extract, and materialize an archive or selected checkpoints.
dict load_reported_storage_profile(args)
Load the storage profile a command will act on, and report its origin.
None storage_plan_workflow(args)
Render a read-only packaging and safety plan.
dict archive_artifact(dict target, dict profile, str label=None, tags=None, str compression=None, bool prune_local=False, str policy=None, keep_latest_checkpoint=None, int workers=None, str notes=None, retain=None, drop=None)
Package, upload, verify, register, and optionally prune one artifact.
None storage_show_workflow(args)
Show one complete remote archive manifest.
None storage_verify_workflow(args)
Verify all remote chunks for one archive.
None storage_restore_workflow(args)
Restore a remote archive by globally unique ID or local marker.
dict build_storage_plan(dict target, dict profile, str compression=None, str policy=None, keep_latest_checkpoint=None, int workers=None, retain=None, drop=None)
Build the read-only plan consumed by protect and offload.
None storage_setup_workflow(args)
Create or update a non-secret workspace storage profile.
Head of a generic C-style linked list.
Definition variables.h:475