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 a run or study changed location.
260 @details Used after a relocated restore and when a moved run or study is next opened.
261 An old path is replaced only where it ends at a path boundary, so moving
262 `/a/run1` does not rewrite `/a/run10`. Immutable stores are never rewritten:
263 an asset object's payload is checked against the digests in its `asset.json`,
264 and a committed checkpoint against its commit marker, so editing either would
265 invalidate it; the paths they carry are provenance, not inputs. A rewritten
266 file replaces the old one instead of being edited in place, so a hard link
267 to it (a run input materialized from an asset object) keeps the original.
268 @param[in] root Directory whose generated text artifacts are rewritten.
269 @param[in] replacements (old absolute path, new absolute path) pairs.
270 @return Paths of the files that changed.
271 """
272 immutable = re.compile(r"(^|/)(assets/objects|checkpoints/step_\d+)/")
273 patterns = [
274 (re.compile(re.escape(old) + r"(?=$|[/\s'\"=,;:)\]}])", re.MULTILINE), new)
275 for old, new in replacements
276 if old and new and old != new
277 ]
278 changed = []
279 allowed_suffixes = {".control", ".run", ".sbatch", ".json", ".tsv", ".yml", ".yaml"}
280 for path in Path(root).rglob("*"):
281 if not path.is_file() or path.is_symlink() or path.stat().st_size > 32 * 1024 * 1024:
282 continue
283 if path.suffix.lower() not in allowed_suffixes:
284 continue
285 if immutable.search(path.relative_to(root).as_posix()):
286 continue
287 try:
288 content = path.read_text(encoding="utf-8")
289 except (OSError, UnicodeDecodeError):
290 continue
291 updated = content
292 for pattern, new in patterns:
293 updated = pattern.sub(lambda _match, value=new: value, updated)
294 if updated != content:
295 staging = path.with_name(f".{path.name}.rebase")
296 staging.write_text(updated, encoding="utf-8")
297 shutil.copymode(path, staging)
298 os.replace(staging, path)
299 changed.append(str(path))
300 return changed