PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
packaging.py
Go to the documentation of this file.
1"""!
2@file packaging.py
3@brief Compression policy, component chunks, and archive extraction.
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 STORAGE_RETENTION_COMPONENTS,
27 ALWAYS_RETAINED_COMPONENTS,
28 AUTO_MAXIMUM_COMPRESSION_BYTES,
29 AUTO_NO_COMPRESSION_BYTES,
30 STORAGE_COMPRESSION_EXTENSIONS,
31 STORAGE_COMPRESSION_POLICIES,
32 STORAGE_OFFLOAD_POLICIES,
33 StorageError,
34 _PARALLEL_GZIP_VALUES,
35)
36
37
38def _select_compression(requested: str, total_bytes: int, profile: dict) -> str:
39 """!
40 @brief Resolve automatic or configured compression policy.
41 @param[in] requested Value supplied through the `requested` argument.
42 @param[in] total_bytes Value supplied through the `total_bytes` argument.
43 @param[in] profile Value supplied through the `profile` argument.
44 @return Result produced by this operation.
45 """
46 selected = requested or profile.get("compression", "auto")
47 selected = str(selected).strip().lower()
48 if selected not in STORAGE_COMPRESSION_POLICIES:
49 raise StorageError("Compression must be one of: auto, none, fast, balanced, maximum.")
50 if selected != "auto":
51 return selected
52 if total_bytes < AUTO_NO_COMPRESSION_BYTES:
53 return "none"
54 if total_bytes >= AUTO_MAXIMUM_COMPRESSION_BYTES:
55 return "maximum"
56 return "balanced"
57
58
59def _chunk_extension(compression: str) -> str:
60 """!
61 @brief Return the archive suffix for a compression policy.
62 @param[in] compression Value supplied through the `compression` argument.
63 @return Result produced by this operation.
64 """
65 return STORAGE_COMPRESSION_EXTENSIONS[compression]
66
67
68def _build_chunk_specs(inventory: dict, chunk_size_bytes: int) -> list:
69 """!
70 @brief Group archive entries into independently transferable component chunks.
71 @param[in] inventory Value supplied through the `inventory` argument.
72 @param[in] chunk_size_bytes Value supplied through the `chunk_size_bytes` argument.
73 @return Result produced by this operation.
74 """
75 groups = {}
76 directory_groups = {}
77 for entry in inventory["entries"]:
78 if entry["type"] == "directory":
79 # A directory belongs to the same component as the files inside it. Sweeping
80 # every directory into `metadata` instead meant restoring one checkpoint
81 # recreated the empty directory tree of every other checkpoint, so a
82 # partially restored run listed steps whose payload was not there.
83 directory_groups.setdefault(entry["component"], []).append(entry["path"])
84 continue
85 groups.setdefault(entry["component"], []).append(entry)
86 specs = []
87 component_order = sorted(
88 set(groups) | set(directory_groups),
89 key=lambda name: (not name.startswith("checkpoint:"), name),
90 )
91 for component in component_order:
92 # Directories ride in the first chunk of their own component, so extracting
93 # that component creates its tree and no one else's.
94 pending_directories = sorted(directory_groups.get(component, []))
95 if component not in groups:
96 if pending_directories:
97 specs.append({
98 "component": component,
99 "entries": pending_directories,
100 "uncompressed_bytes": 0,
101 })
102 continue
103 current = list(pending_directories)
104 current_bytes = 0
105 for entry in groups[component]:
106 entry_size = max(1, int(entry["size"]))
107 if current and current_bytes + entry_size > chunk_size_bytes:
108 specs.append({"component": component, "entries": current, "uncompressed_bytes": current_bytes})
109 current = []
110 current_bytes = 0
111 current.append(entry["path"])
112 current_bytes += int(entry["size"])
113 if current:
114 specs.append({"component": component, "entries": current, "uncompressed_bytes": current_bytes})
115 return specs
116
117
118def _safe_component_name(component: str) -> str:
119 """!
120 @brief Convert a component token into a portable archive filename fragment.
121 @param[in] component Value supplied through the `component` argument.
122 @return Result produced by this operation.
123 """
124 return re.sub(r"[^A-Za-z0-9_.-]+", "-", component).strip("-") or "data"
125
126
127def _write_tar_chunk(root: str, spec: dict, destination: str, compression: str,
128 workers: int = 1) -> str:
129 """!
130 @brief Package explicitly inventoried entries without following symlinks.
131 @param[in] root Value supplied through the `root` argument.
132 @param[in] spec Value supplied through the `spec` argument.
133 @param[in] destination Value supplied through the `destination` argument.
134 @param[in] compression Value supplied through the `compression` argument.
135 @param[in] workers Maximum compressor worker count.
136 @return Compressor implementation used for the archive chunk.
137 """
138 tar_executable = shutil.which("tar")
139 compressor = None
140 compressor_args = []
141 if compression in _PARALLEL_GZIP_VALUES and shutil.which("pigz"):
142 compressor = shutil.which("pigz")
143 compressor_args = [compressor, "-c", "-p", str(workers), "-1" if compression == "fast" else "-6"]
144 elif compression == "maximum" and shutil.which("xz"):
145 compressor = shutil.which("xz")
146 compressor_args = [compressor, "-c", "-T", str(workers), "-9e"]
147 if compressor and tar_executable:
148 list_file = tempfile.NamedTemporaryFile(prefix="picurv-tar-list-", delete=False)
149 try:
150 list_file.write(b"\0".join(path.encode("utf-8") for path in spec["entries"]) + b"\0")
151 list_file.close()
152 tar_command = [
153 tar_executable, "-C", root, "--null", "--verbatim-files-from", "--no-recursion",
154 "-T", list_file.name, "-cf", "-",
155 ]
156 with tempfile.TemporaryFile() as tar_stderr, open(destination, "wb") as output:
157 producer = subprocess.Popen(tar_command, stdout=subprocess.PIPE, stderr=tar_stderr)
158 consumer = subprocess.Popen(
159 compressor_args, stdin=producer.stdout, stdout=output, stderr=subprocess.PIPE
160 )
161 producer.stdout.close()
162 _, compressor_stderr = consumer.communicate()
163 producer_code = producer.wait()
164 if producer_code != 0 or consumer.returncode != 0:
165 tar_stderr.seek(0)
166 detail = (
167 tar_stderr.read().decode("utf-8", "replace")
168 or compressor_stderr.decode("utf-8", "replace")
169 or "parallel archive command failed"
170 ).strip()
171 raise StorageError(detail)
172 return f"{os.path.basename(compressor)}:{workers}"
173 finally:
174 try:
175 os.remove(list_file.name)
176 except FileNotFoundError:
177 pass
178
179 kwargs = {}
180 if compression == "none":
181 mode = "w"
182 elif compression == "fast":
183 mode = "w:gz"
184 kwargs["compresslevel"] = 1
185 elif compression == "balanced":
186 mode = "w:gz"
187 kwargs["compresslevel"] = 6
188 else:
189 mode = "w:xz"
190 kwargs["preset"] = 9
191 with tarfile.open(destination, mode, dereference=False, **kwargs) as archive:
192 for relative in spec["entries"]:
193 source = os.path.join(root, *relative.split("/"))
194 if not os.path.lexists(source):
195 raise StorageError(f"Artifact changed during packaging; entry disappeared: {source}")
196 archive.add(source, arcname=relative, recursive=False)
197 return "python-tarfile"
198
199
200def _normalize_retention_selection(values, flag: str) -> set:
201 """!
202 @brief Validate one --retain/--drop selection into a component name set.
203 @param[in] values Repeated component names from the command line, or None.
204 @param[in] flag User-facing flag name, for error messages.
205 @return Set of selectable component names.
206 """
207 selected = set()
208 for raw in values or []:
209 for token in str(raw).split(","):
210 name = token.strip()
211 if not name:
212 continue
213 if name not in STORAGE_RETENTION_COMPONENTS:
214 raise StorageError(
215 f"{flag} {name!r} is not a selectable component. Choose from: "
216 + ", ".join(STORAGE_RETENTION_COMPONENTS)
217 )
218 selected.add(name)
219 return selected
220
221
222def _resolve_offload_policy(profile: dict, requested: str = None,
223 keep_latest_checkpoint=None, retain=None, drop=None) -> dict:
224 """!
225 @brief Resolve semantic local-retention behavior for an offload.
226
227 @details A named policy is a preset, not a ceiling. `--retain`/`--drop` adjust the
228 preset one component at a time, so a campaign that needs, say, the
229 analysis of `analysis-ready` but also its checkpoints does not have to
230 choose the policy that happens to bundle both. The preset is still what
231 decides everything not named explicitly.
232 @param[in] profile Active storage profile.
233 @param[in] requested Optional command-level policy override.
234 @param[in] keep_latest_checkpoint Optional checkpoint-retention override.
235 @param[in] retain Component names to retain locally regardless of the preset.
236 @param[in] drop Component names to prune locally regardless of the preset.
237 @return Normalized retention policy mapping.
238 """
239 name = requested or profile.get("offload_policy", "metadata-only")
240 if name not in STORAGE_OFFLOAD_POLICIES:
241 raise StorageError("Offload policy must be one of: " + ", ".join(STORAGE_OFFLOAD_POLICIES))
242 retain_set = _normalize_retention_selection(retain, "--retain")
243 drop_set = _normalize_retention_selection(drop, "--drop")
244 conflicting = retain_set & drop_set
245 if conflicting:
246 raise StorageError(
247 "A component cannot be both retained and dropped: " + ", ".join(sorted(conflicting))
248 )
249 # The conflict is between two things the user actually asked for, so it is decided
250 # on the requested value: the derived default is False far more often than
251 # --drop-all-checkpoints was typed, and refusing on that would reject the ordinary
252 # `--retain checkpoints` with no second flag at all.
253 if "checkpoints" in retain_set and keep_latest_checkpoint is False:
254 raise StorageError(
255 "--retain checkpoints keeps every committed step, which contradicts "
256 "--drop-all-checkpoints. Pass one or the other."
257 )
258 if keep_latest_checkpoint is None:
259 # Nothing explicit was requested: fall back to the profile default, or to
260 # `restart-ready`'s own promise to keep the newest checkpoint. An explicit
261 # --keep-latest-checkpoint/--drop-all-checkpoints always overrides both,
262 # rather than being silently overruled by the policy it was paired with.
263 keep_latest_checkpoint = bool(profile.get("keep_latest_checkpoint", False)) or name == "restart-ready"
264 retained = set({
265 "metadata-only": {"metadata", "logs"},
266 "restart-ready": {"metadata", "logs", "inputs"},
267 "analysis-ready": {"metadata", "logs", "analysis", "visualization"},
268 }[name])
269 retained |= retain_set
270 retained -= drop_set
271 # Identity is never optional: a cold artifact that cannot say what it is, what it
272 # ran, and what it consumed cannot be restored or reasoned about.
273 retained.add("metadata")
274 return {
275 "name": name,
276 "retained_components": sorted(retained),
277 "explicit_retain": sorted(retain_set),
278 "explicit_drop": sorted(drop_set),
279 "keep_latest_checkpoint": bool(keep_latest_checkpoint),
280 }
281
282
283def _entry_retained_by_policy(entry: dict, policy: dict, latest_step) -> bool:
284 """!
285 @brief Return whether one inventoried entry remains local after offload.
286 @param[in] entry Inventoried artifact entry.
287 @param[in] policy Normalized offload policy.
288 @param[in] latest_step Newest committed checkpoint step, if any.
289 @return True when the entry is retained locally.
290 """
291 component = entry["component"]
292 # Never pruned, whatever the policy: a file storage cannot classify, and a
293 # workspace's editable configuration and user-supplied inputs. Protecting a
294 # workspace is a backup, not a handover of the user's own files.
295 if component in ALWAYS_RETAINED_COMPONENTS:
296 return True
297 retained = set(policy["retained_components"])
298 if component.startswith("checkpoint:"):
299 # "checkpoints" retains every committed step; --keep-latest-checkpoint is the
300 # narrower selection of just the newest one.
301 if "checkpoints" in retained:
302 return True
303 return bool(
304 policy["keep_latest_checkpoint"]
305 and latest_step is not None
306 and component == f"checkpoint:{latest_step}"
307 )
308 return component in retained
309
310
311def _compression_size_range(source_bytes: int, compression: str) -> tuple:
312 """!
313 @brief Return a deliberately broad planning estimate, never a promised ratio.
314 @param[in] source_bytes Uncompressed payload byte count.
315 @param[in] compression Selected compression policy.
316 @return Estimated low and high archive sizes in bytes.
317 """
318 ratios = {
319 "none": (1.0, 1.03),
320 "fast": (0.45, 0.90),
321 "balanced": (0.35, 0.85),
322 "maximum": (0.25, 0.80),
323 }
324 low, high = ratios[compression]
325 return int(source_bytes * low), int(source_bytes * high)
326
327
328def _validate_tar_members(archive: tarfile.TarFile) -> None:
329 """!
330 @brief Reject archive members that could escape the restore destination.
331 @param[in] archive Value supplied through the `archive` argument.
332 """
333 link_paths = set()
334 members = archive.getmembers()
335 for member in members:
336 normalized = os.path.normpath(member.name.replace("\\", "/"))
337 if normalized.startswith("../") or normalized == ".." or os.path.isabs(normalized):
338 raise StorageError(f"Unsafe archive member path: {member.name}")
339 for link_path in link_paths:
340 if normalized == link_path or normalized.startswith(link_path.rstrip("/") + "/"):
341 raise StorageError(f"Archive member traverses an earlier symlink: {member.name}")
342 if member.issym() or member.islnk():
343 if member.islnk():
344 link_target = os.path.normpath(member.linkname.replace("\\", "/"))
345 if link_target.startswith("../") or link_target == ".." or os.path.isabs(link_target):
346 raise StorageError(f"Unsafe archive hardlink target: {member.linkname}")
347 link_paths.add(normalized)
348
349
350def _extract_chunk(path: str, destination: str) -> None:
351 """!
352 @brief Safely extract one verified tar chunk into a staging tree.
353 @param[in] path Value supplied through the `path` argument.
354 @param[in] destination Value supplied through the `destination` argument.
355 """
356 with tarfile.open(path, "r:*") as archive:
357 _validate_tar_members(archive)
358 try:
359 archive.extractall(destination, filter="data")
360 except TypeError:
361 archive.extractall(destination)
362
363
364def _merge_tree(source: str, destination: str) -> None:
365 """!
366 @brief Merge a verified restore tree into a known cold artifact skeleton.
367 @param[in] source Value supplied through the `source` argument.
368 @param[in] destination Value supplied through the `destination` argument.
369 """
370 os.makedirs(destination, exist_ok=True)
371 for entry in os.scandir(source):
372 target = os.path.join(destination, entry.name)
373 if entry.is_symlink():
374 if os.path.lexists(target):
375 if os.path.isdir(target) and not os.path.islink(target):
376 shutil.rmtree(target)
377 else:
378 os.remove(target)
379 os.symlink(os.readlink(entry.path), target)
380 elif entry.is_dir(follow_symlinks=False):
381 _merge_tree(entry.path, target)
382 else:
383 os.makedirs(os.path.dirname(target), exist_ok=True)
384 shutil.copy2(entry.path, target)
User-facing storage workflow failure.
Definition models.py:139
set _normalize_retention_selection(values, str flag)
Validate one –retain/–drop selection into a component name set.
Definition packaging.py:200
Head of a generic C-style linked list.
Definition variables.h:475