PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
compatibility.py
Go to the documentation of this file.
1"""!
2@file compatibility.py
3@brief Provenance capture and path rebasing across restores.
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 _sha256_file,
27)
28from .inventory import (
29 _artifact_runtime_roots,
30)
31
32
33def _capture_study_context(target: dict) -> list:
34 """!
35 @brief Embed small study control-plane files with an individually archived member.
36 @param[in] target Value supplied through the `target` argument.
37 @return Result produced by this operation.
38 """
39 if target["artifact_type"] != "study-case":
40 return []
41 study_root = Path(target["study_path"])
42 candidates = [
43 study_root / "study.yml",
44 study_root / "cluster.yml",
45 study_root / "study_manifest.json",
46 study_root / "scheduler" / "case_index.tsv",
47 study_root / "scheduler" / "submission.json",
48 ]
49 captured = []
50 for path in candidates:
51 if not path.is_file() or path.stat().st_size > 5 * 1024 * 1024:
52 continue
53 captured.append({
54 "path": path.relative_to(study_root).as_posix(),
55 "mode": int(path.stat().st_mode & 0o7777),
56 "content_base64": base64.b64encode(path.read_bytes()).decode("ascii"),
57 })
58 return captured
59
60
61def _capture_parameter_summary(root: str) -> dict:
62 """!
63 @brief Capture the few case values that identify what a run actually solved.
64
65 @details `storage show` has to answer "which run was this?" months later without
66 restoring anything, and an id plus a label does not. These are read from
67 the run's own configuration snapshot, so they describe the run rather than
68 whatever the editable workspace says now.
69 @param[in] root Artifact root directory.
70 @return Parameter summary, empty when no readable case snapshot exists.
71 """
72 for relative in (("config", "case.yml"), ("cases", "case_0000", "config", "case.yml")):
73 case_path = os.path.join(root, *relative)
74 if os.path.isfile(case_path):
75 break
76 else:
77 return {}
78 try:
79 with open(case_path, "r", encoding="utf-8") as stream:
80 case = yaml.safe_load(stream) or {}
81 except (OSError, ValueError):
82 return {}
83 if not isinstance(case, dict):
84 return {}
85 properties = case.get("properties") or {}
86 scaling = properties.get("scaling") or {}
87 fluid = properties.get("fluid") or {}
88 grid = case.get("grid") or {}
89 run_control = case.get("run_control") or {}
90 summary = {
91 "title": case.get("title"),
92 "grid_mode": grid.get("mode"),
93 "grid_dimensions": None,
94 "blocks": ((case.get("models") or {}).get("domain") or {}).get("blocks"),
95 "start_step": run_control.get("start_step"),
96 "total_steps": run_control.get("total_steps"),
97 "dt_physical": run_control.get("dt_physical"),
98 "length_ref": scaling.get("length_ref"),
99 "velocity_ref": scaling.get("velocity_ref"),
100 "density": fluid.get("density"),
101 "viscosity": fluid.get("viscosity"),
102 "reynolds": None,
103 }
104 programmatic = grid.get("programmatic_settings")
105 if isinstance(programmatic, dict):
106 dims = [programmatic.get(key) for key in ("im", "jm", "km")]
107 if all(isinstance(value, int) for value in dims):
108 summary["grid_dimensions"] = dims
109 try:
110 summary["reynolds"] = (
111 float(fluid["density"]) * float(scaling["velocity_ref"])
112 * float(scaling["length_ref"]) / float(fluid["viscosity"])
113 )
114 except (KeyError, TypeError, ValueError, ZeroDivisionError):
115 summary["reynolds"] = None
116 return {key: value for key, value in summary.items() if value is not None}
117
118
119def _capture_workspace_assets(target: dict) -> list:
120 """!
121 @brief List the asset ids a workspace archive carries.
122 @param[in] target Local artifact target.
123 @return Sorted asset ids, empty for non-workspace artifacts.
124 """
125 if target["artifact_type"] != "workspace":
126 return []
127 objects_root = os.path.join(target["root_path"], "assets", "objects")
128 if not os.path.isdir(objects_root):
129 return []
130 found = []
131 for kind in sorted(os.listdir(objects_root)):
132 kind_root = os.path.join(objects_root, kind)
133 if os.path.isdir(kind_root):
134 found.extend(
135 name for name in sorted(os.listdir(kind_root))
136 if os.path.isdir(os.path.join(kind_root, name))
137 )
138 return sorted(found)
139
140
141def _capture_run_assets(root: str) -> list:
142 """!
143 @brief Record reusable asset references carried by run-local input snapshots.
144 @param[in] root Run or study artifact root.
145 @return Portable asset references for the remote archive catalog.
146 """
147 captured = []
148 archive_root = Path(os.path.abspath(root))
149 for runtime_root_text in _artifact_runtime_roots(root):
150 runtime_root = Path(runtime_root_text)
151 lock_path = runtime_root / "inputs" / "assets.lock.yml"
152 if not lock_path.is_file():
153 continue
154 try:
155 payload = yaml.safe_load(lock_path.read_text(encoding="utf-8")) or {}
156 except (OSError, ValueError, TypeError):
157 continue
158 assets = payload.get("assets") if isinstance(payload, dict) else None
159 if not isinstance(assets, dict):
160 continue
161 runtime_prefix = runtime_root.relative_to(archive_root).as_posix()
162 for kind, reference in sorted(assets.items()):
163 if not isinstance(reference, dict):
164 continue
165 files = reference.get("files") or reference.get("exposed") or []
166 normalized_files = []
167 for item in files:
168 if not isinstance(item, dict) or not isinstance(item.get("path"), str):
169 continue
170 run_path = item["path"]
171 archived_path = (
172 f"{runtime_prefix}/{run_path}" if runtime_prefix != "." else run_path
173 )
174 normalized_files.append({
175 "path": run_path,
176 "archived_path": archived_path,
177 "bytes": item.get("bytes"),
178 "sha256": item.get("sha256"),
179 })
180 captured.append({
181 "kind": kind,
182 "asset_id": reference.get("asset_id"),
183 "provider": reference.get("provider"),
184 "provider_spec_sha256": reference.get("provider_spec_sha256"),
185 "files": normalized_files,
186 })
187 return captured
188
189
190def _config_fingerprints(root: str) -> dict:
191 """!
192 @brief Hash small canonical YAML inputs stored under an artifact.
193 @param[in] root Value supplied through the `root` argument.
194 @return Result produced by this operation.
195 """
196 result = {}
197 root_path = Path(root)
198 candidates = set(root_path.glob("config/*.yml"))
199 candidates.update(root_path.glob("base_configs/*.yml"))
200 candidates.update(root_path.glob("cases/case_*/config/*.yml"))
201 for path in sorted(candidates):
202 result[path.relative_to(root).as_posix()] = _sha256_file(str(path))
203 for name in ("study.yml", "cluster.yml"):
204 path = root_path / name
205 if path.is_file():
206 result[name] = _sha256_file(str(path))
207 return result
208
209
210def _git_provenance(root: str) -> dict:
211 """!
212 @brief Record best-effort current source revision and dirty state.
213 @param[in] root Value supplied through the `root` argument.
214 @return Result produced by this operation.
215 """
216 result = {"commit": None, "dirty": None}
217 try:
218 commit = subprocess.run(
219 ["git", "rev-parse", "HEAD"], cwd=root, text=True, capture_output=True, check=False
220 )
221 status = subprocess.run(
222 ["git", "status", "--porcelain"], cwd=root, text=True, capture_output=True, check=False
223 )
224 if commit.returncode == 0:
225 result["commit"] = commit.stdout.strip()
226 if status.returncode == 0:
227 result["dirty"] = bool(status.stdout.strip())
228 except OSError:
229 pass
230 return result
231
232
233def _restore_study_context(manifest: dict, case_destination: str) -> None:
234 """!
235 @brief Recreate missing study control-plane files around a restored member.
236 @param[in] manifest Value supplied through the `manifest` argument.
237 @param[in] case_destination Value supplied through the `case_destination` argument.
238 """
239 if manifest.get("artifact_type") != "study-case":
240 return
241 study_root = Path(case_destination).parent.parent
242 for item in manifest.get("study_context", []):
243 relative = item.get("path")
244 if not isinstance(relative, str):
245 continue
246 destination = study_root.joinpath(*relative.split("/"))
247 if destination.exists():
248 continue
249 destination.parent.mkdir(parents=True, exist_ok=True)
250 destination.write_bytes(base64.b64decode(item.get("content_base64", "")))
251 try:
252 destination.chmod(int(item.get("mode", 0o644)))
253 except OSError:
254 pass
255
256
257def _rebase_restored_text_paths(root: str, replacements: list) -> list:
258 """!
259 @brief Rebase known generated text artifacts after an explicit relocated restore.
260 @param[in] root Value supplied through the `root` argument.
261 @param[in] replacements Value supplied through the `replacements` argument.
262 @return Result produced by this operation.
263 """
264 changed = []
265 allowed_suffixes = {".control", ".run", ".sbatch", ".json", ".tsv", ".yml", ".yaml"}
266 for path in Path(root).rglob("*"):
267 if not path.is_file() or path.is_symlink() or path.stat().st_size > 32 * 1024 * 1024:
268 continue
269 if path.suffix.lower() not in allowed_suffixes:
270 continue
271 try:
272 content = path.read_text(encoding="utf-8")
273 except (OSError, UnicodeDecodeError):
274 continue
275 updated = content
276 for old, new in replacements:
277 if old and new and old != new:
278 updated = updated.replace(old, new)
279 if updated != content:
280 path.write_text(updated, encoding="utf-8")
281 changed.append(str(path))
282 return changed