PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
transport.py
Go to the documentation of this file.
1"""!
2@file transport.py
3@brief The rclone process boundary and remote object addressing.
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 ARCHIVE_ID_PATTERN,
27 REMOTE_BLOBS_DIRECTORY,
28 REMOTE_COMPLETE_FILENAME,
29 REMOTE_MANIFEST_FILENAME,
30 REMOTE_OBJECTS_DIRECTORY,
31 STORAGE_SCHEMA_VERSION,
32 StorageError,
33 _sha256_file,
34)
35
36
37def _remote_join(remote: str, *parts: str) -> str:
38 """!
39 @brief Join path components without corrupting rclone remote syntax.
40 @param[in] remote Value supplied through the `remote` argument.
41 @param[in] parts Value supplied through the `parts` argument.
42 @return Result produced by this operation.
43 """
44 clean_parts = [str(part).strip("/") for part in parts if str(part).strip("/")]
45 suffix = "/".join(clean_parts)
46 if not suffix:
47 return remote
48 if remote.endswith(":"):
49 return remote + suffix
50 return remote.rstrip("/") + "/" + suffix
51
52
53def _object_remote(profile: dict, archive_id: str, *parts: str) -> str:
54 """!
55 @brief Return the remote path for one immutable archive object.
56 @param[in] profile Value supplied through the `profile` argument.
57 @param[in] archive_id Value supplied through the `archive_id` argument.
58 @param[in] parts Value supplied through the `parts` argument.
59 @return Result produced by this operation.
60 """
61 return _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY, archive_id, *parts)
62
63
64def _blob_remote(profile: dict, digest: str) -> str:
65 """!
66 @brief Return the remote path of one content-addressed payload blob.
67 @param[in] profile Active storage profile.
68 @param[in] digest Payload SHA-256.
69 @return Remote path, fanned out by digest prefix to keep directories small.
70 """
71 return _remote_join(profile["remote"], REMOTE_BLOBS_DIRECTORY, digest[:2], digest)
72
73
74def _chunk_remote_path(profile: dict, archive_id: str, chunk: dict) -> str:
75 """!
76 @brief Resolve where one manifest chunk's payload lives on the remote.
77
78 @details Every chunk this schema writes is blob-addressed, resolved out of the
79 shared content-addressed store. `_load_remote_manifest` already refuses
80 any manifest whose schema version does not match the current one, so a
81 non-blob chunk cannot reach this function from a manifest this build
82 actually loads.
83 @param[in] profile Active storage profile.
84 @param[in] archive_id Owning archive id.
85 @param[in] chunk Manifest chunk entry.
86 @return Remote path of the chunk payload.
87 """
88 if chunk.get("blob"):
89 return _blob_remote(profile, chunk["sha256"])
90 return _object_remote(profile, archive_id, "chunks", chunk["name"])
91
92
93def _remote_blob_present(profile: dict, digest: str) -> bool:
94 """!
95 @brief Whether a blob with this digest is already stored and intact.
96 @param[in] profile Active storage profile.
97 @param[in] digest Payload SHA-256.
98 @return True when the remote already holds exactly this content.
99 """
100 try:
101 return _remote_sha256(_blob_remote(profile, digest)) == digest
102 except (StorageError, OSError):
103 # An absent blob is the normal case, not an error; anything else that stops us
104 # from confirming it is treated the same way, and the chunk is uploaded.
105 return False
106
107
108def _run_rclone(arguments: list, check: bool = True) -> subprocess.CompletedProcess:
109 """!
110 @brief Invoke rclone through the same argv-based subprocess boundary as other PICurv tools.
111 @param[in] arguments Value supplied through the `arguments` argument.
112 @param[in] check Value supplied through the `check` argument.
113 @return Result produced by this operation.
114 """
115 executable = shutil.which("rclone")
116 if not executable:
117 raise StorageError("rclone was not found on PATH. Install/configure rclone before using PICurv storage.")
118 result = subprocess.run(
119 [executable] + [str(item) for item in arguments],
120 text=True,
121 capture_output=True,
122 check=False,
123 )
124 if check and result.returncode != 0:
125 detail = (result.stderr or result.stdout or "unknown rclone error").strip()
126 raise StorageError(f"rclone {' '.join(str(item) for item in arguments[:2])} failed: {detail}")
127 return result
128
129
130def _remote_sha256(remote_path: str) -> str:
131 """!
132 @brief Ask rclone to calculate or retrieve the SHA-256 of one remote object.
133 @param[in] remote_path Value supplied through the `remote_path` argument.
134 @return Result produced by this operation.
135 """
136 result = _run_rclone(["hashsum", "SHA-256", remote_path])
137 for line in result.stdout.splitlines():
138 token = line.strip().split(None, 1)[0] if line.strip() else ""
139 if re.fullmatch(r"[0-9a-fA-F]{64}", token):
140 return token.lower()
141 raise StorageError(f"rclone did not return a SHA-256 for {remote_path}.")
142
143
144def _upload_verified(local_path: str, remote_path: str) -> dict:
145 """!
146 @brief Upload one file, then verify its remote SHA-256.
147 @param[in] local_path Value supplied through the `local_path` argument.
148 @param[in] remote_path Value supplied through the `remote_path` argument.
149 @return Result produced by this operation.
150 """
151 local_digest = _sha256_file(local_path)
152 _run_rclone(["copyto", local_path, remote_path])
153 remote_digest = _remote_sha256(remote_path)
154 if remote_digest != local_digest:
155 raise StorageError(
156 f"Remote checksum mismatch after upload: {remote_path} "
157 f"(local {local_digest}, remote {remote_digest})."
158 )
159 return {"sha256": local_digest, "stored_bytes": os.path.getsize(local_path)}
160
161
162def _read_remote_bytes(remote_path: str) -> bytes:
163 """!
164 @brief Read a small remote catalog object through rclone.
165 @param[in] remote_path Value supplied through the `remote_path` argument.
166 @return Result produced by this operation.
167 """
168 executable = shutil.which("rclone")
169 if not executable:
170 raise StorageError("rclone was not found on PATH.")
171 result = subprocess.run(
172 [executable, "cat", remote_path], capture_output=True, check=False
173 )
174 if result.returncode != 0:
175 detail = (result.stderr or result.stdout or b"unknown rclone error").decode("utf-8", "replace").strip()
176 raise StorageError(f"Unable to read remote object {remote_path}: {detail}")
177 return result.stdout
178
179
180def _load_remote_manifest(profile: dict, archive_id: str, require_complete: bool = True) -> dict:
181 """!
182 @brief Fetch and validate one versioned remote storage manifest.
183 @param[in] profile Value supplied through the `profile` argument.
184 @param[in] archive_id Value supplied through the `archive_id` argument.
185 @param[in] require_complete Value supplied through the `require_complete` argument.
186 @return Result produced by this operation.
187 """
188 if not ARCHIVE_ID_PATTERN.fullmatch(str(archive_id)):
189 raise StorageError(f"Invalid archive ID: {archive_id!r}.")
190 manifest_bytes = _read_remote_bytes(_object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
191 if require_complete:
192 complete = _read_remote_bytes(_object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
193 recorded = complete.decode("ascii", "replace").strip().lower()
194 actual = hashlib.sha256(manifest_bytes).hexdigest()
195 if recorded != actual:
196 raise StorageError(f"Archive {archive_id} has no valid completion marker.")
197 try:
198 manifest = json.loads(manifest_bytes.decode("utf-8"))
199 except (UnicodeDecodeError, ValueError) as exc:
200 raise StorageError(f"Archive {archive_id} has an invalid manifest.") from exc
201 if not isinstance(manifest, dict) or manifest.get("storage_schema_version") != STORAGE_SCHEMA_VERSION:
202 raise StorageError(
203 f"Archive {archive_id} uses unsupported storage schema "
204 f"{manifest.get('storage_schema_version') if isinstance(manifest, dict) else 'unknown'}."
205 )
206 return manifest
User-facing storage workflow failure.
Definition models.py:139