boatramp_node/blob_migrate.rs
1//! Offline, node-local **blob-backend migration** — the copy engine behind
2//! `boatramp blob migrate --from <config> --to <config>` (Part 1 of the
3//! blob-backend-migration feature).
4//!
5//! Switching the node blob backend (`--blobs fs|s3|gcs|azure`, or provider→provider,
6//! or region→region) points serving at an EMPTY store, so every site on the node 404s
7//! until each project is re-applied. This engine copies the existing objects from a
8//! source backend to a destination backend so the switch has no re-upload step.
9//!
10//! It operates over the existing [`Storage`] trait primitives — `list`/`head`/`get`/`put`
11//! — so it works uniformly across every backend (fs, S3, GCS, Azure). The CLI builds the
12//! two backends from two node config files via [`build_blobs`](crate::blobs::build_blobs)
13//! and calls [`migrate`]; the engine itself is backend-agnostic (it takes two
14//! `Arc<dyn Storage>`), which is exactly what lets the mutation gate drive it fs→fs.
15//!
16//! ## Guarantees
17//! - **Read-only on the source.** The engine NEVER deletes (or writes) the source. The
18//! operator flips `--blobs`/config after verifying and retires the old store separately.
19//! - **Key fidelity.** A destination key is byte-for-byte the source key (no prefix
20//! mangling that could collapse a tenant/`hblob` boundary). The `content_type` is
21//! preserved from the source metadata.
22//! - **Idempotent / resumable.** A `head`-present destination object of matching size is
23//! skipped, so a re-run after an interruption is a near-no-op.
24//! - **Completeness (verified).** With `--verify` (default on) every source key is
25//! confirmed `head`-present in the destination after the copy; any missing key is a
26//! hard error (non-zero exit).
27
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::{Duration, Instant};
31
32use boatramp_core::{ByteStream, PutMeta, Storage, StorageError};
33use futures::StreamExt;
34
35/// The gate-mutation seams for the Part-1 `BLOB MIGRATE COMPLETE OK` battery. Compiled ONLY
36/// under the `blob-migrate-gate-mutation` feature (the CI gate lane). Each
37/// `BOATRAMP_BLOBMIG_MUTATE_*` env var makes the engine behave like a specific broken
38/// implementation, so the CI gate proves each invariant is load-bearing (every mutation MUST
39/// fail the gate). Mirrors the #505 `s3_credential::gate_mutation` seam exactly.
40#[cfg(feature = "blob-migrate-gate-mutation")]
41pub(crate) mod gate_mutation {
42 /// Whether a mutation env var is set (non-empty and not `0`).
43 pub(crate) fn env_on(name: &str) -> bool {
44 std::env::var(name)
45 .map(|v| !v.is_empty() && v != "0")
46 .unwrap_or(false)
47 }
48}
49
50/// Options controlling a [`migrate`] run.
51#[derive(Debug, Clone)]
52pub struct MigrateOptions {
53 /// Bounded worker concurrency for the copy loop (≥ 1; clamped up to 1).
54 pub concurrency: usize,
55 /// Verify (default on) that every source object is `head`-present in the destination
56 /// after the copy; any missing key is an error.
57 pub verify: bool,
58 /// Enumerate + classify (would-copy / would-skip) but copy nothing.
59 pub dry_run: bool,
60 /// Restrict the enumeration to keys under this prefix (default `""` = all).
61 pub prefix: String,
62}
63
64impl Default for MigrateOptions {
65 fn default() -> Self {
66 Self {
67 concurrency: 8,
68 verify: true,
69 dry_run: false,
70 prefix: String::new(),
71 }
72 }
73}
74
75/// A summary of a completed [`migrate`] run.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct MigrateReport {
78 /// Total source objects enumerated under the prefix.
79 pub total_objects: u64,
80 /// Objects copied source→dest (or, on `--dry-run`, that WOULD be copied).
81 pub copied_objects: u64,
82 /// Objects skipped because the destination already had the key at matching size
83 /// (or, on `--dry-run`, that WOULD be skipped).
84 pub skipped_objects: u64,
85 /// Total bytes copied (0 on `--dry-run`; sums the source-reported sizes on a real copy).
86 pub copied_bytes: u64,
87 /// Whether verification ran (`opts.verify` and not a dry-run).
88 pub verified: bool,
89}
90
91/// A failure running the migration.
92#[derive(Debug, thiserror::Error)]
93pub enum MigrateError {
94 /// Enumerating / reading / writing an object failed.
95 #[error("blob storage: {0}")]
96 Storage(#[from] StorageError),
97 /// Post-copy verification found source objects absent from the destination. The list is
98 /// capped ([`VERIFY_REPORT_CAP`]) so a large miss set does not flood the terminal.
99 #[error(
100 "verification FAILED: {missing} source object(s) absent from the destination \
101 (first {shown} shown): {keys:?}"
102 )]
103 VerifyMissing {
104 /// How many source keys were found missing in the destination.
105 missing: usize,
106 /// How many of them are listed in `keys` (capped).
107 shown: usize,
108 /// The (capped) list of missing keys.
109 keys: Vec<String>,
110 },
111}
112
113/// How many missing keys a [`MigrateError::VerifyMissing`] lists before truncating.
114pub const VERIFY_REPORT_CAP: usize = 50;
115
116/// One classified object after the destination `head` check.
117enum PlanItem {
118 /// The destination lacks the key (or has it at a different size) — it must be copied.
119 Copy {
120 key: String,
121 size: u64,
122 content_type: Option<String>,
123 },
124 /// The destination already has the key at matching size — skip (idempotent/resumable).
125 Skip,
126}
127
128/// The outcome of processing one source object.
129struct ItemOutcome {
130 copied: bool,
131 bytes: u64,
132}
133
134/// Copy every object under `opts.prefix` from `source` to `dest`, skipping any already
135/// present at matching size, then (when `opts.verify`) confirm every source key is present
136/// in the destination.
137///
138/// The source is treated as read-only — the engine never deletes or writes it. On success
139/// it returns a [`MigrateReport`]; a storage error or a failed verification returns
140/// [`MigrateError`] (the CLI maps this to a non-zero exit).
141pub async fn migrate(
142 source: Arc<dyn Storage>,
143 dest: Arc<dyn Storage>,
144 opts: &MigrateOptions,
145) -> Result<MigrateReport, MigrateError> {
146 let concurrency = opts.concurrency.max(1);
147
148 // 1. Enumerate the source. `list` is not paginated at the trait — each backend flattens
149 // internally (fs = recursive read_dir; s3/gcs/azure = native paged ListObjects loop).
150 // `mut` is consumed only by the DROP_LAST gate seam below; a non-gate build never mutates it.
151 #[cfg_attr(not(feature = "blob-migrate-gate-mutation"), allow(unused_mut))]
152 let mut objects = source.list(&opts.prefix).await?;
153 // The reported total counts the FULL source enumeration — even under the DROP_LAST mutation,
154 // so `total_objects` still reflects the true source set (verification re-enumerates the source
155 // independently and finds the dropped object absent in the dest).
156 let total_objects = objects.len() as u64;
157
158 // GATE MUTATION SEAM (I1 completeness): drop the LAST source object from the COPY set so it is
159 // never written to the destination. Verification independently re-enumerates the source and
160 // `head`s every key in the dest, so the dropped key is found absent ⇒ I1 FAIL.
161 #[cfg(feature = "blob-migrate-gate-mutation")]
162 if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_DROP_LAST") {
163 // A deterministic "last" — sort so the dropped key is stable regardless of list order.
164 objects.sort_by(|a, b| a.key.cmp(&b.key));
165 objects.pop();
166 }
167 tracing::info!(
168 total = total_objects,
169 prefix = %opts.prefix,
170 dry_run = opts.dry_run,
171 concurrency,
172 "blob migrate: enumerated source objects"
173 );
174
175 // Shared progress counters (updated from the concurrent workers).
176 let copied_objects = Arc::new(AtomicU64::new(0));
177 let skipped_objects = Arc::new(AtomicU64::new(0));
178 let copied_bytes = Arc::new(AtomicU64::new(0));
179 let done_objects = Arc::new(AtomicU64::new(0));
180 let progress = Arc::new(std::sync::Mutex::new(Progress::new(total_objects)));
181
182 // 2. Bounded-concurrency copy. `buffer_unordered(N)` runs at most `N` per-object futures
183 // at once, and yields each `Result` so a storage error short-circuits the whole run.
184 let results: Vec<Result<ItemOutcome, MigrateError>> = futures::stream::iter(objects)
185 .map(|meta| {
186 let source = source.clone();
187 let dest = dest.clone();
188 let dry_run = opts.dry_run;
189 async move { copy_one(&source, &dest, meta, dry_run).await }
190 })
191 .buffer_unordered(concurrency)
192 .map(|outcome| {
193 // Fold each completed object into the shared counters + periodic progress line.
194 if let Ok(ref o) = outcome {
195 if o.copied {
196 copied_objects.fetch_add(1, Ordering::Relaxed);
197 copied_bytes.fetch_add(o.bytes, Ordering::Relaxed);
198 } else {
199 skipped_objects.fetch_add(1, Ordering::Relaxed);
200 }
201 }
202 let done = done_objects.fetch_add(1, Ordering::Relaxed) + 1;
203 let (c, s, b) = (
204 copied_objects.load(Ordering::Relaxed),
205 skipped_objects.load(Ordering::Relaxed),
206 copied_bytes.load(Ordering::Relaxed),
207 );
208 if let Ok(mut p) = progress.lock() {
209 p.maybe_log(done, c, s, b, opts.dry_run);
210 }
211 outcome
212 })
213 .collect()
214 .await;
215
216 // Surface the first storage error (if any) — a copy failure aborts before verification.
217 for r in results {
218 r?;
219 }
220
221 let copied_objects = copied_objects.load(Ordering::Relaxed);
222 let skipped_objects = skipped_objects.load(Ordering::Relaxed);
223 let copied_bytes = copied_bytes.load(Ordering::Relaxed);
224
225 // A final summary line (always), independent of the periodic cadence.
226 tracing::info!(
227 total = total_objects,
228 copied = copied_objects,
229 skipped = skipped_objects,
230 copied_bytes,
231 dry_run = opts.dry_run,
232 "blob migrate: copy phase complete"
233 );
234
235 // 3. Verify (default on; skipped on a dry-run — nothing was written to confirm). Re-enumerate
236 // the SOURCE and `head` each key in the DESTINATION; collect any that are absent. This is
237 // the completeness invariant (I1) + the key-fidelity invariant (I3, since a mangled dest
238 // key would leave the true source key absent).
239 let mut verified = false;
240 if opts.verify && !opts.dry_run {
241 let missing = verify(&source, &dest, &opts.prefix, concurrency).await?;
242 if !missing.is_empty() {
243 let shown = missing.len().min(VERIFY_REPORT_CAP);
244 return Err(MigrateError::VerifyMissing {
245 missing: missing.len(),
246 shown,
247 keys: missing.into_iter().take(VERIFY_REPORT_CAP).collect(),
248 });
249 }
250 verified = true;
251 tracing::info!(
252 objects = total_objects,
253 "VERIFY OK: all source objects present in destination"
254 );
255 }
256
257 Ok(MigrateReport {
258 total_objects,
259 copied_objects,
260 skipped_objects,
261 copied_bytes,
262 verified,
263 })
264}
265
266/// `head` the destination for `key`; classify whether it must be copied. Under the
267/// `SKIP_ALWAYS` mutation, unconditionally report "skip" (a broken head-check that would leave
268/// a not-present key missing — breaks I1/I2).
269async fn plan_one(
270 dest: &Arc<dyn Storage>,
271 key: &str,
272 source_size: u64,
273 content_type: Option<String>,
274) -> Result<PlanItem, MigrateError> {
275 // GATE MUTATION SEAM (I2 head-skip soundness): skip unconditionally, even when the dest does
276 // NOT have the key. The gate then finds that key absent in verification ⇒ I1 FAIL.
277 #[cfg(feature = "blob-migrate-gate-mutation")]
278 if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_SKIP_ALWAYS") {
279 return Ok(PlanItem::Skip);
280 }
281
282 match dest.head(key).await {
283 // Present at matching size ⇒ idempotent skip.
284 Ok(meta) if meta.size == Some(source_size) => Ok(PlanItem::Skip),
285 // Present at a different size, or absent ⇒ (re)copy.
286 Ok(_) => Ok(PlanItem::Copy {
287 key: key.to_string(),
288 size: source_size,
289 content_type,
290 }),
291 Err(StorageError::NotFound(_)) => Ok(PlanItem::Copy {
292 key: key.to_string(),
293 size: source_size,
294 content_type,
295 }),
296 // A transient/backend error is NOT a miss — propagate it (never a silent copy-or-skip).
297 Err(e) => Err(MigrateError::Storage(e)),
298 }
299}
300
301/// Process one source object: head-check the destination, then (unless dry-run or skipped)
302/// stream the source body into the destination preserving the key + content_type.
303async fn copy_one(
304 source: &Arc<dyn Storage>,
305 dest: &Arc<dyn Storage>,
306 meta: boatramp_core::ObjectMeta,
307 dry_run: bool,
308) -> Result<ItemOutcome, MigrateError> {
309 let source_size = meta.size.unwrap_or(0);
310 let plan = plan_one(dest, &meta.key, source_size, meta.content_type.clone()).await?;
311 match plan {
312 PlanItem::Skip => Ok(ItemOutcome {
313 copied: false,
314 bytes: 0,
315 }),
316 PlanItem::Copy {
317 key,
318 size,
319 content_type,
320 } => {
321 if dry_run {
322 // Classify only — never get/put on a dry-run.
323 return Ok(ItemOutcome {
324 copied: true,
325 bytes: 0,
326 });
327 }
328 // Stream the source body straight into the destination — no full-object buffering.
329 // The source `get` yields the authoritative content_type; fall back to the list meta.
330 let got = source.get(&key).await?;
331 let ct = got.meta.content_type.or(content_type);
332 let dest_key = dest_key_for(&key);
333 let body: ByteStream = got.body;
334 let written = dest
335 .put(&dest_key, body, PutMeta { content_type: ct })
336 .await?;
337
338 // GATE MUTATION SEAM (I4 source read-only): delete from the SOURCE after copying — the
339 // exact "never delete from source" regression. The gate asserts the source object set is
340 // unchanged, so this must fail it.
341 #[cfg(feature = "blob-migrate-gate-mutation")]
342 if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_DELETE_SOURCE") {
343 source.delete(&key).await?;
344 }
345
346 Ok(ItemOutcome {
347 copied: true,
348 bytes: written.size.unwrap_or(size),
349 })
350 }
351 }
352}
353
354/// The destination key for a source key. Identity — a destination key is byte-for-byte the
355/// source key (I3 key fidelity). Under the `REWRITE_KEY` mutation, prepend a mangled segment so
356/// the true source key is never written to the destination (verification then finds it absent).
357fn dest_key_for(source_key: &str) -> String {
358 // GATE MUTATION SEAM (I3 key fidelity): mangle the destination key. Verification `head`s the
359 // ORIGINAL source key in the destination and finds it absent ⇒ FAIL. In a real build this is a
360 // straight identity copy.
361 #[cfg(feature = "blob-migrate-gate-mutation")]
362 if gate_mutation::env_on("BOATRAMP_BLOBMIG_MUTATE_REWRITE_KEY") {
363 return format!("MANGLED/{source_key}");
364 }
365 source_key.to_string()
366}
367
368/// Confirm every source key under `prefix` is `head`-present in the destination; return the keys
369/// that are absent (empty ⇒ complete). Bounded-concurrency `head` probes.
370async fn verify(
371 source: &Arc<dyn Storage>,
372 dest: &Arc<dyn Storage>,
373 prefix: &str,
374 concurrency: usize,
375) -> Result<Vec<String>, MigrateError> {
376 // Re-enumerate the SOURCE independently of the copy set. This is what makes the DROP_LAST
377 // mutation observable: the copy loop dropped a key, but verification still lists it here and
378 // finds it absent in the destination.
379 let source_keys = source.list(prefix).await?;
380
381 let checks: Vec<Result<Option<String>, MigrateError>> = futures::stream::iter(source_keys)
382 .map(|meta| {
383 let dest = dest.clone();
384 async move {
385 match dest.head(&meta.key).await {
386 Ok(_) => Ok(None),
387 Err(StorageError::NotFound(_)) => Ok(Some(meta.key)),
388 Err(e) => Err(MigrateError::Storage(e)),
389 }
390 }
391 })
392 .buffer_unordered(concurrency)
393 .collect()
394 .await;
395
396 let mut missing = Vec::new();
397 for c in checks {
398 if let Some(key) = c? {
399 missing.push(key);
400 }
401 }
402 missing.sort();
403 Ok(missing)
404}
405
406/// Periodic progress logger — a line roughly every [`PROGRESS_INTERVAL`] or every
407/// [`PROGRESS_EVERY_N`] objects, whichever comes first.
408struct Progress {
409 total: u64,
410 last: Instant,
411}
412
413const PROGRESS_INTERVAL: Duration = Duration::from_secs(2);
414const PROGRESS_EVERY_N: u64 = 500;
415
416impl Progress {
417 fn new(total: u64) -> Self {
418 Self {
419 total,
420 last: Instant::now(),
421 }
422 }
423
424 fn maybe_log(&mut self, done: u64, copied: u64, skipped: u64, bytes: u64, dry_run: bool) {
425 let elapsed = self.last.elapsed();
426 if elapsed >= PROGRESS_INTERVAL
427 || done.is_multiple_of(PROGRESS_EVERY_N)
428 || done == self.total
429 {
430 self.last = Instant::now();
431 tracing::info!(
432 done,
433 total = self.total,
434 copied,
435 skipped,
436 copied_bytes = bytes,
437 dry_run,
438 "blob migrate: progress"
439 );
440 }
441 }
442}
443
444// ================================================================================================
445// The Part-1 mutation-verified gate battery: `BLOB MIGRATE COMPLETE OK`.
446//
447// One `#[tokio::test]` runs every invariant fs→fs in a tempdir (no cloud), then prints the marker.
448// Compiled ONLY under the `blob-migrate-gate-mutation` feature (the CI gate lane); each
449// `BOATRAMP_BLOBMIG_MUTATE_*` env var neuters exactly ONE invariant (via the seams above), so a
450// clean run reaches the marker while every mutation PANICS before it — proving each check is
451// load-bearing. Mirrors the #505 `s3_credential::gate` structure exactly. See the ci.yml gate step.
452// ================================================================================================
453#[cfg(all(test, feature = "blob-migrate-gate-mutation"))]
454mod gate {
455 use super::*;
456 use boatramp_storage::FsStorage;
457
458 /// A representative slice of the ONE node blob keyspace: an immutable content-addressed deploy
459 /// blob (`{2hex}/{64hex}`), a mutable control-plane manifest record, and a mutable guest object
460 /// (`hblob/{qualified-site}/{container}/{key}`). Migrating must move all three faithfully.
461 fn seed_keys() -> Vec<(&'static str, &'static [u8])> {
462 vec![
463 (
464 "ab/0000000000000000000000000000000000000000000000000000000000000000",
465 b"content-addressed-immutable-blob",
466 ),
467 ("manifests/site-alpha", b"{\"deployment\":\"d1\"}"),
468 ("hblob/proj~site/uploads/report.json", b"{\"ok\":true}"),
469 ]
470 }
471
472 async fn put(store: &Arc<dyn Storage>, key: &str, bytes: &[u8]) {
473 let owned = bytes.to_vec();
474 let body: ByteStream =
475 futures::stream::once(async move { Ok(bytes::Bytes::from(owned)) }).boxed();
476 store
477 .put(key, body, PutMeta::default())
478 .await
479 .expect("seed put");
480 }
481
482 /// Build a fresh fs source backend seeded with the representative keyspace, plus an empty fs
483 /// destination. Two distinct tempdir roots so source + dest never alias.
484 async fn seeded_backends(
485 tmp: &std::path::Path,
486 ) -> (Arc<dyn Storage>, Arc<dyn Storage>, Vec<String>) {
487 let source: Arc<dyn Storage> = Arc::new(FsStorage::new(tmp.join("src")));
488 let dest: Arc<dyn Storage> = Arc::new(FsStorage::new(tmp.join("dst")));
489 let mut keys = Vec::new();
490 for (k, v) in seed_keys() {
491 put(&source, k, v).await;
492 keys.push(k.to_string());
493 }
494 keys.sort();
495 (source, dest, keys)
496 }
497
498 /// The sorted set of keys currently present in a backend (list `""`).
499 async fn keys_of(store: &Arc<dyn Storage>) -> Vec<String> {
500 let mut ks: Vec<String> = store
501 .list("")
502 .await
503 .expect("list")
504 .into_iter()
505 .map(|m| m.key)
506 .collect();
507 ks.sort();
508 ks
509 }
510
511 /// Invariant 1 (completeness): after migrate, EVERY source key is `head`-present in the dest.
512 async fn invariant_1_completeness(dest: &Arc<dyn Storage>, source_keys: &[String]) {
513 for key in source_keys {
514 dest.head(key).await.unwrap_or_else(|_| {
515 panic!("I1 completeness: source key {key:?} is absent from the destination")
516 });
517 }
518 }
519
520 /// Invariant 2 (head-skip soundness): a skip only happens when the dest already holds the key at
521 /// matching size. We assert it by proving a SECOND run (dest now fully populated) copies nothing
522 /// AND still leaves every key present — i.e. the skip decision never dropped a key. The
523 /// SKIP_ALWAYS mutation makes the FIRST run skip a not-present key, which I1 catches.
524 async fn invariant_2_head_skip_sound(
525 source: &Arc<dyn Storage>,
526 dest: &Arc<dyn Storage>,
527 source_keys: &[String],
528 ) {
529 let report = migrate(source.clone(), dest.clone(), &MigrateOptions::default())
530 .await
531 .expect("second migrate (idempotent) succeeds");
532 assert_eq!(
533 report.copied_objects, 0,
534 "I2: a re-run over a fully-populated destination must copy nothing (all head-skipped)"
535 );
536 assert_eq!(
537 report.skipped_objects,
538 source_keys.len() as u64,
539 "I2: every object must be head-skipped on the idempotent re-run"
540 );
541 // And the skip must not have dropped anything.
542 invariant_1_completeness(dest, source_keys).await;
543 }
544
545 /// Invariant 3 (key fidelity): the dest key set equals the source key set, byte-exact. The
546 /// REWRITE_KEY mutation prepends `MANGLED/`, so the dest keys diverge from the source keys.
547 async fn invariant_3_key_fidelity(source: &Arc<dyn Storage>, dest: &Arc<dyn Storage>) {
548 let src = keys_of(source).await;
549 let dst = keys_of(dest).await;
550 assert_eq!(
551 src, dst,
552 "I3 key fidelity: destination keys must equal source keys byte-exact (no mangling)"
553 );
554 }
555
556 /// Invariant 4 (source read-only): the source key set is unchanged after migrate. The
557 /// DELETE_SOURCE mutation deletes each copied object from the source.
558 async fn invariant_4_source_read_only(source: &Arc<dyn Storage>, before: &[String]) {
559 let after = keys_of(source).await;
560 assert_eq!(
561 before,
562 after.as_slice(),
563 "I4 source read-only: the source object set must be unchanged after migrate"
564 );
565 }
566
567 #[tokio::test]
568 async fn blob_migrate_complete_gate() {
569 let tmp = tempfile::tempdir().expect("tempdir");
570 let (source, dest, source_keys) = seeded_backends(tmp.path()).await;
571 let before_source = keys_of(&source).await;
572 assert_eq!(before_source, source_keys, "sanity: seeded source keys");
573
574 // The real migration under test (verify ON) — a mutation makes it break an invariant below.
575 let report = migrate(source.clone(), dest.clone(), &MigrateOptions::default())
576 .await
577 .expect("migrate succeeds on a clean run");
578 assert_eq!(report.total_objects, source_keys.len() as u64);
579 assert!(report.verified, "verify must have run + passed");
580
581 invariant_1_completeness(&dest, &source_keys).await;
582 // I4 before I3: DELETE_SOURCE empties the source, so check "source unchanged" against its own
583 // invariant before the source-vs-dest key-set comparison (both catch it — panic ⇒ marker
584 // absent — but this attributes it to the load-bearing check).
585 invariant_4_source_read_only(&source, &before_source).await;
586 invariant_3_key_fidelity(&source, &dest).await;
587 invariant_2_head_skip_sound(&source, &dest, &source_keys).await;
588
589 // Reached only on a clean, fully-passing run — a mutation env var panics one invariant above
590 // (DROP_LAST/SKIP_ALWAYS/REWRITE_KEY via I1/I3; DELETE_SOURCE via I4).
591 println!(
592 "BLOB MIGRATE COMPLETE OK: the offline blob-backend migration copies every source \
593 object to the destination (completeness), skips only a matching-size head-present key \
594 (idempotent/resumable), preserves keys byte-exact (no tenant/hblob-boundary collapse), \
595 and never mutates the source (read-only). Mutation-verified: \
596 BOATRAMP_BLOBMIG_MUTATE_{{DROP_LAST,SKIP_ALWAYS,REWRITE_KEY,DELETE_SOURCE}}=1 each FAIL \
597 this gate."
598 );
599 }
600}