38def _select_compression(requested: str, total_bytes: int, profile: dict) -> str:
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.
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":
52 if total_bytes < AUTO_NO_COMPRESSION_BYTES:
54 if total_bytes >= AUTO_MAXIMUM_COMPRESSION_BYTES:
68def _build_chunk_specs(inventory: dict, chunk_size_bytes: int) -> list:
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.
77 for entry
in inventory[
"entries"]:
78 if entry[
"type"] ==
"directory":
83 directory_groups.setdefault(entry[
"component"], []).append(entry[
"path"])
85 groups.setdefault(entry[
"component"], []).append(entry)
87 component_order = sorted(
88 set(groups) | set(directory_groups),
89 key=
lambda name: (
not name.startswith(
"checkpoint:"), name),
91 for component
in component_order:
94 pending_directories = sorted(directory_groups.get(component, []))
95 if component
not in groups:
96 if pending_directories:
98 "component": component,
99 "entries": pending_directories,
100 "uncompressed_bytes": 0,
103 current =
list(pending_directories)
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})
111 current.append(entry[
"path"])
112 current_bytes += int(entry[
"size"])
114 specs.append({
"component": component,
"entries": current,
"uncompressed_bytes": current_bytes})
127def _write_tar_chunk(root: str, spec: dict, destination: str, compression: str,
128 workers: int = 1) -> str:
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.
138 tar_executable = shutil.which(
"tar")
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)
150 list_file.write(b
"\0".join(path.encode(
"utf-8")
for path
in spec[
"entries"]) + b
"\0")
153 tar_executable,
"-C", root,
"--null",
"--verbatim-files-from",
"--no-recursion",
154 "-T", list_file.name,
"-cf",
"-",
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
161 producer.stdout.close()
162 _, compressor_stderr = consumer.communicate()
163 producer_code = producer.wait()
164 if producer_code != 0
or consumer.returncode != 0:
167 tar_stderr.read().decode(
"utf-8",
"replace")
168 or compressor_stderr.decode(
"utf-8",
"replace")
169 or "parallel archive command failed"
172 return f
"{os.path.basename(compressor)}:{workers}"
175 os.remove(list_file.name)
176 except FileNotFoundError:
180 if compression ==
"none":
182 elif compression ==
"fast":
184 kwargs[
"compresslevel"] = 1
185 elif compression ==
"balanced":
187 kwargs[
"compresslevel"] = 6
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"
222def _resolve_offload_policy(profile: dict, requested: str =
None,
223 keep_latest_checkpoint=
None, retain=
None, drop=
None) -> dict:
225 @brief Resolve semantic local-retention behavior for an offload.
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.
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))
244 conflicting = retain_set & drop_set
247 "A component cannot be both retained and dropped: " +
", ".join(sorted(conflicting))
253 if "checkpoints" in retain_set
and keep_latest_checkpoint
is False:
255 "--retain checkpoints keeps every committed step, which contradicts "
256 "--drop-all-checkpoints. Pass one or the other."
258 if keep_latest_checkpoint
is None:
263 keep_latest_checkpoint = bool(profile.get(
"keep_latest_checkpoint",
False))
or name ==
"restart-ready"
265 "metadata-only": {
"metadata",
"logs"},
266 "restart-ready": {
"metadata",
"logs",
"inputs"},
267 "analysis-ready": {
"metadata",
"logs",
"analysis",
"visualization"},
269 retained |= retain_set
273 retained.add(
"metadata")
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),
311def _compression_size_range(source_bytes: int, compression: str) -> tuple:
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.
320 "fast": (0.45, 0.90),
321 "balanced": (0.35, 0.85),
322 "maximum": (0.25, 0.80),
324 low, high = ratios[compression]
325 return int(source_bytes * low), int(source_bytes * high)
328def _validate_tar_members(archive: tarfile.TarFile) ->
None:
330 @brief Reject archive members that could escape the restore destination.
331 @param[in] archive Value supplied through the `archive` argument.
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():
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)
350def _extract_chunk(path: str, destination: str) ->
None:
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.
356 with tarfile.open(path,
"r:*")
as archive:
357 _validate_tar_members(archive)
359 archive.extractall(destination, filter=
"data")
361 archive.extractall(destination)
364def _merge_tree(source: str, destination: str) ->
None:
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.
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)
379 os.symlink(os.readlink(entry.path), target)
380 elif entry.is_dir(follow_symlinks=
False):
381 _merge_tree(entry.path, target)
383 os.makedirs(os.path.dirname(target), exist_ok=
True)
384 shutil.copy2(entry.path, target)