1use std::{io::Cursor, path::PathBuf};
2
3use serde::Serialize;
4use shardline_index::{parse_xet_hash_hex, xet_hash_hex_string};
5use shardline_storage::{
6 ObjectBody, ObjectIntegrity, ObjectKey, ObjectPrefix, ObjectStore, PutOutcome,
7 S3ObjectStoreConfig,
8};
9use shardline_xet_core::merklehash::compute_data_hash;
10
11use crate::{
12 ServerError,
13 chunk_store::chunk_hash_from_chunk_object_key_if_present,
14 error::ObjectStoreError,
15 local_backend::chunk_hash,
16 object_store::{ServerObjectStore, read_full_object},
17 overflow::checked_add,
18 xet_adapter::{
19 shard_hash_from_object_key_if_present, validate_serialized_xorb,
20 xorb_hash_from_object_key_if_present,
21 },
22};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum StorageMigrationEndpoint {
27 LocalStateRoot(PathBuf),
29 S3(S3ObjectStoreConfig),
31}
32
33impl StorageMigrationEndpoint {
34 const fn backend_name(&self) -> &'static str {
35 match self {
36 Self::LocalStateRoot(_root) => "local",
37 Self::S3(_config) => "s3",
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct StorageMigrationOptions {
45 source: StorageMigrationEndpoint,
46 destination: StorageMigrationEndpoint,
47 prefix: String,
48 dry_run: bool,
49}
50
51impl StorageMigrationOptions {
52 #[must_use]
54 pub const fn new(
55 source: StorageMigrationEndpoint,
56 destination: StorageMigrationEndpoint,
57 ) -> Self {
58 Self {
59 source,
60 destination,
61 prefix: String::new(),
62 dry_run: false,
63 }
64 }
65
66 #[must_use]
68 pub fn with_prefix(mut self, prefix: String) -> Self {
69 self.prefix = prefix;
70 self
71 }
72
73 #[must_use]
75 pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
76 self.dry_run = dry_run;
77 self
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct StorageMigrationReport {
84 pub source_backend: String,
86 pub destination_backend: String,
88 pub prefix: String,
90 pub dry_run: bool,
92 pub scanned_objects: u64,
94 pub inserted_objects: u64,
96 pub already_present_objects: u64,
98 pub copied_bytes: u64,
100 pub scanned_bytes: u64,
102}
103
104impl StorageMigrationReport {
105 fn new(options: &StorageMigrationOptions) -> Self {
106 Self {
107 source_backend: options.source.backend_name().to_owned(),
108 destination_backend: options.destination.backend_name().to_owned(),
109 prefix: options.prefix.clone(),
110 dry_run: options.dry_run,
111 scanned_objects: 0,
112 inserted_objects: 0,
113 already_present_objects: 0,
114 copied_bytes: 0,
115 scanned_bytes: 0,
116 }
117 }
118}
119
120pub fn run_storage_migration(
130 options: &StorageMigrationOptions,
131) -> Result<StorageMigrationReport, ServerError> {
132 let source = endpoint_store(&options.source)?;
133 let destination = endpoint_store(&options.destination)?;
134 let prefix = ObjectPrefix::parse(&options.prefix)?;
135 let mut report = StorageMigrationReport::new(options);
136
137 crate::object_store::visit_object_prefix(&source, &prefix, |metadata| {
138 report.scanned_objects = checked_add(report.scanned_objects, 1)?;
139 report.scanned_bytes = checked_add(report.scanned_bytes, metadata.length())?;
140 if options.dry_run {
141 return Ok(());
142 }
143
144 let bytes = read_full_object(&source, metadata.key(), metadata.length())?;
145 let observed_length = u64::try_from(bytes.len())?;
146 validate_source_object_matches_content_addressed_key(metadata.key(), &bytes)?;
147 let integrity = ObjectIntegrity::new(chunk_hash(&bytes), observed_length);
148 let outcome =
149 destination.put_if_absent(metadata.key(), ObjectBody::from_vec(bytes), &integrity)?;
150 match outcome {
151 PutOutcome::Inserted => {
152 report.inserted_objects = checked_add(report.inserted_objects, 1)?;
153 report.copied_bytes = checked_add(report.copied_bytes, observed_length)?;
154 }
155 PutOutcome::AlreadyExists => {
156 report.already_present_objects = checked_add(report.already_present_objects, 1)?;
157 }
158 }
159
160 Ok(())
161 })?;
162
163 Ok(report)
164}
165
166fn validate_source_object_matches_content_addressed_key(
167 key: &ObjectKey,
168 bytes: &[u8],
169) -> Result<(), ServerError> {
170 if let Some(expected_hash) = chunk_hash_from_chunk_object_key_if_present(key)? {
171 let observed_hash = xet_hash_hex_string(chunk_hash(bytes));
172 ensure_observed_hash_matches_key(key, expected_hash, &observed_hash)?;
173 }
174
175 if let Some(expected_hash) = xorb_hash_from_object_key_if_present(key)? {
176 let expected_hash = parse_xet_hash_hex(expected_hash)?;
177 let mut cursor = Cursor::new(bytes);
178 validate_serialized_xorb(&mut cursor, expected_hash)?;
179 }
180
181 if let Some(expected_hash) = shard_hash_from_object_key_if_present(key)? {
182 let observed_hash = compute_data_hash(bytes).hex();
183 ensure_observed_hash_matches_key(key, expected_hash, &observed_hash)?;
184 }
185
186 Ok(())
187}
188
189fn ensure_observed_hash_matches_key(
190 key: &ObjectKey,
191 expected_hash: &str,
192 observed_hash: &str,
193) -> Result<(), ServerError> {
194 if observed_hash == expected_hash {
195 return Ok(());
196 }
197
198 Err(ServerError::ObjectStore(
199 ObjectStoreError::MigrationSourceHashMismatch {
200 key: key.as_str().to_owned(),
201 expected_hash: expected_hash.to_owned(),
202 observed_hash: observed_hash.to_owned(),
203 },
204 ))
205}
206
207fn endpoint_store(endpoint: &StorageMigrationEndpoint) -> Result<ServerObjectStore, ServerError> {
208 match endpoint {
209 StorageMigrationEndpoint::LocalStateRoot(root) => {
210 Ok(ServerObjectStore::local(root.join("chunks"))?)
211 }
212 StorageMigrationEndpoint::S3(config) => Ok(ServerObjectStore::s3(config.clone())?),
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use std::fs;
219
220 use shardline_storage::{ObjectBody, ObjectIntegrity, ObjectKey, ObjectStore};
221
222 use super::{StorageMigrationEndpoint, StorageMigrationOptions, run_storage_migration};
223 use crate::{
224 chunk_store::chunk_object_key_for_computed_hash, local_backend::chunk_hash,
225 object_store::ServerObjectStore,
226 };
227
228 #[test]
229 fn storage_migration_copies_local_objects_idempotently() {
230 let source = tempfile::tempdir();
231 assert!(source.is_ok());
232 let Ok(source) = source else {
233 return;
234 };
235 let destination = tempfile::tempdir();
236 assert!(destination.is_ok());
237 let Ok(destination) = destination else {
238 return;
239 };
240 let source_store = ServerObjectStore::local(source.path().join("chunks"));
241 assert!(source_store.is_ok());
242 let Ok(source_store) = source_store else {
243 return;
244 };
245 let body = b"payload";
246 let key = chunk_object_key_for_computed_hash(chunk_hash(body)).map(|(_hash, key)| key);
247 assert!(key.is_ok());
248 let Ok(key) = key else {
249 return;
250 };
251 let integrity = ObjectIntegrity::new(chunk_hash(body), 7);
252 let put = source_store.put_if_absent(&key, ObjectBody::from_slice(body), &integrity);
253 assert!(put.is_ok());
254
255 let options = StorageMigrationOptions::new(
256 StorageMigrationEndpoint::LocalStateRoot(source.path().to_path_buf()),
257 StorageMigrationEndpoint::LocalStateRoot(destination.path().to_path_buf()),
258 );
259 let first = run_storage_migration(&options);
260 assert!(first.is_ok());
261 if let Ok(first) = first {
262 assert_eq!(first.scanned_objects, 1);
263 assert_eq!(first.inserted_objects, 1);
264 assert_eq!(first.already_present_objects, 0);
265 assert_eq!(first.copied_bytes, 7);
266 }
267
268 let second = run_storage_migration(&options);
269 assert!(second.is_ok());
270 if let Ok(second) = second {
271 assert_eq!(second.scanned_objects, 1);
272 assert_eq!(second.inserted_objects, 0);
273 assert_eq!(second.already_present_objects, 1);
274 }
275 }
276
277 #[test]
278 fn storage_migration_rejects_corrupt_source_chunk_key() {
279 let source = tempfile::tempdir();
280 assert!(source.is_ok());
281 let Ok(source) = source else {
282 return;
283 };
284 let destination = tempfile::tempdir();
285 assert!(destination.is_ok());
286 let Ok(destination) = destination else {
287 return;
288 };
289 let key =
290 ObjectKey::parse("aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
291 assert!(key.is_ok());
292 let Ok(key) = key else {
293 return;
294 };
295 let source_path = source.path().join("chunks").join(key.as_str());
296 let parent = source_path.parent();
297 assert!(parent.is_some());
298 let Some(parent) = parent else {
299 return;
300 };
301 let created = fs::create_dir_all(parent);
302 assert!(created.is_ok());
303 let written = fs::write(&source_path, b"corrupt-source-bytes");
304 assert!(written.is_ok());
305
306 let options = StorageMigrationOptions::new(
307 StorageMigrationEndpoint::LocalStateRoot(source.path().to_path_buf()),
308 StorageMigrationEndpoint::LocalStateRoot(destination.path().to_path_buf()),
309 );
310 let migrated = run_storage_migration(&options);
311
312 assert!(
313 migrated.is_err(),
314 "storage migration copied a source chunk whose bytes did not match its content-addressed key"
315 );
316 assert!(
317 !destination
318 .path()
319 .join("chunks")
320 .join(key.as_str())
321 .exists(),
322 "storage migration wrote corrupt source bytes into the destination under the original key"
323 );
324 }
325}