Skip to main content

krabka_backup/
run.rs

1//! What each subcommand does, and the one place that talks to a cluster.
2//!
3//! Public operations return an [`OperationReport`] with their result and ordered diagnostics.
4//! Only [`crate::run()`] renders those diagnostics and maps a [`BackupError`] to an exit code.
5
6use {
7    crate::{
8        archive::{Archive, ArchiveArgs},
9        capture::{
10            MetadataCheckpoint, RLMM_SNAPSHOT_RELATIVE, capture_id, capture_key, holds_a_topic,
11            newest_metadata_checkpoint,
12        },
13        cli::{CaptureSigningArgs, CaptureTrustArgs, LATEST},
14        connection::SecurityArgs,
15        diskless::capture_projection,
16        error::BackupError,
17        manifest::{
18            Artifact, CAPTURE_ROOT, DISKLESS_WAL_INDEX, GROUP_OFFSETS, MANIFEST, MANIFEST_VERSION,
19            METADATA_CHECKPOINT, Manifest, RLMM_SNAPSHOT, artifact_problem, sha256_hex,
20        },
21        offsets::{GroupOffsetsFile, admin_offsets, commit_outcome, group_offsets},
22        report::{BackupEvent, OperationReport},
23    },
24    krabka_audit::FileEd25519Signer,
25    krabka_client_admin::{
26        AdminClient, AdminError, ConfigResource, DescribeConfigsOptions, TopicMetadata,
27        groups::ListGroupsOptions,
28    },
29    krabka_client_core::security::ClientSecurity,
30    krabka_raft::{deserialize_metadata_snapshot, metadata_snapshot_last_contained_log_timestamp},
31    krabka_remote_storage::{ObjectEntry, Sha256Digest, diskless::capture::DisklessWalCapture},
32    krabka_remote_storage_topic::{KafkaMetadataEventLog, KafkaMetadataLogConfig},
33    std::{
34        collections::{BTreeMap, BTreeSet, HashMap},
35        io::{Error, ErrorKind},
36        path::Path,
37        sync::Arc,
38        time::{self, SystemTime},
39    },
40    tokio::fs,
41};
42
43const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
44const MAX_GROUP_OFFSETS_BYTES: u64 = 64 * 1024 * 1024;
45const MAX_SNAPSHOT_BYTES: u64 = 512 * 1024 * 1024;
46const MAX_DISKLESS_INDEX_BYTES: u64 = 256 * 1024 * 1024;
47
48fn artifact_byte_limit(name: &str) -> u64 {
49    match name {
50        MANIFEST => MAX_MANIFEST_BYTES,
51        GROUP_OFFSETS => MAX_GROUP_OFFSETS_BYTES,
52        METADATA_CHECKPOINT | RLMM_SNAPSHOT => MAX_SNAPSHOT_BYTES,
53        _ => MAX_DISKLESS_INDEX_BYTES,
54    }
55}
56
57fn check_capture_size(name: &str, size: u64, max_bytes: u64) -> Result<(), BackupError> {
58    if size > max_bytes {
59        return Err(BackupError::Io(Error::new(
60            ErrorKind::InvalidData,
61            format!(
62                "capture artifact `{name}` is {size} bytes, exceeds restore limit of {max_bytes} bytes"
63            ),
64        )));
65    }
66    Ok(())
67}
68
69async fn read_capture_file(path: &Path, name: &str) -> Result<Vec<u8>, BackupError> {
70    check_capture_size(
71        name,
72        fs::metadata(path).await?.len(),
73        artifact_byte_limit(name),
74    )?;
75    let bytes = fs::read(path).await?;
76    // The file can grow between its metadata check and the read.
77    check_capture_size(name, bytes.len() as u64, artifact_byte_limit(name))?;
78    Ok(bytes)
79}
80
81/// Milliseconds since the Unix epoch, saturating at zero on a clock set before
82/// it. The value names a capture directory and nothing branches on it, so a
83/// nonsensical clock produces an odd name rather than a failure.
84fn now_ms() -> u64 {
85    u64::try_from(
86        SystemTime::now()
87            .duration_since(time::UNIX_EPOCH)
88            .map_or(0, |elapsed| elapsed.as_millis()),
89    )
90    .unwrap_or(0)
91}
92
93/// Copy this node's restore inputs into the archive.
94///
95/// # Errors
96///
97/// The report result returns [`BackupError::Io`] when the capture found nothing to take or an
98/// artifact exceeds its restore size limit, and the archive's or the cluster's
99/// own error otherwise.
100pub async fn capture(
101    log_dir: Option<&Path>,
102    bootstrap_server: Option<&str>,
103    archive: &ArchiveArgs,
104) -> OperationReport<String> {
105    let mut events = Vec::new();
106    let result = capture_secured_inner(
107        log_dir,
108        bootstrap_server,
109        None,
110        &CaptureSigningArgs::default(),
111        archive,
112        &mut events,
113    )
114    .await;
115    OperationReport { result, events }
116}
117
118/// Load command-config security and capture this node's restore inputs.
119///
120/// # Errors
121///
122/// The report result returns the same errors as [`capture`], plus command-config read or parse
123/// failures.
124pub async fn capture_configured(
125    log_dir: Option<&Path>,
126    bootstrap_server: Option<&str>,
127    security: &SecurityArgs,
128    signing: &CaptureSigningArgs,
129    archive: &ArchiveArgs,
130) -> OperationReport<String> {
131    let mut events = Vec::new();
132    let result = async {
133        let client_security = match bootstrap_server {
134            Some(_) => security.load().await?,
135            None => None,
136        };
137        capture_secured_inner(
138            log_dir,
139            bootstrap_server,
140            client_security,
141            signing,
142            archive,
143            &mut events,
144        )
145        .await
146    }
147    .await;
148    OperationReport { result, events }
149}
150
151async fn capture_secured_inner(
152    log_dir: Option<&Path>,
153    bootstrap_server: Option<&str>,
154    security: Option<ClientSecurity>,
155    signing: &CaptureSigningArgs,
156    archive: &ArchiveArgs,
157    events: &mut Vec<BackupEvent>,
158) -> Result<String, BackupError> {
159    capture_secured_at_inner(
160        log_dir,
161        bootstrap_server,
162        security,
163        signing,
164        archive,
165        now_ms(),
166        events,
167    )
168    .await
169}
170
171#[cfg(test)]
172async fn capture_secured_at(
173    log_dir: Option<&Path>,
174    bootstrap_server: Option<&str>,
175    security: Option<ClientSecurity>,
176    signing: &CaptureSigningArgs,
177    archive: &ArchiveArgs,
178    captured_at_ms: u64,
179) -> OperationReport<String> {
180    let mut events = Vec::new();
181    let result = capture_secured_at_inner(
182        log_dir,
183        bootstrap_server,
184        security,
185        signing,
186        archive,
187        captured_at_ms,
188        &mut events,
189    )
190    .await;
191    OperationReport { result, events }
192}
193
194async fn capture_secured_at_inner(
195    log_dir: Option<&Path>,
196    bootstrap_server: Option<&str>,
197    security: Option<ClientSecurity>,
198    signing: &CaptureSigningArgs,
199    archive: &ArchiveArgs,
200    captured_at_ms: u64,
201    events: &mut Vec<BackupEvent>,
202) -> Result<String, BackupError> {
203    let store = Archive::open(archive)?;
204    let id = capture_id(captured_at_ms);
205    let mut artifacts: Vec<Artifact> = Vec::new();
206    let mut absent: Vec<String> = Vec::new();
207    let mut metadata_snapshot_sha256 = None;
208    let mut rlmm_snapshot_sha256 = None;
209    let mut notes: Vec<String> = Vec::new();
210
211    if let Some(log_dir) = log_dir {
212        // Read and vet the checkpoint before anything is uploaded. A refused
213        // capture then leaves no orphan artifact in the archive, and the
214        // controller cannot delete the chosen file while the RLMM snapshot
215        // uploads.
216        let checkpoint = match newest_metadata_checkpoint(log_dir) {
217            Some(checkpoint) => {
218                let bytes = read_capture_file(&checkpoint.path, METADATA_CHECKPOINT).await?;
219                refuse_format_time_checkpoint(&checkpoint, &bytes)?;
220                Some((checkpoint, bytes))
221            }
222            None => None,
223        };
224
225        let rlmm = log_dir.join(RLMM_SNAPSHOT_RELATIVE);
226        match read_capture_file(&rlmm, RLMM_SNAPSHOT).await {
227            Ok(bytes) => {
228                rlmm_snapshot_sha256 = Some(Sha256Digest::of(&bytes));
229                artifacts.push(
230                    upload(
231                        &store,
232                        &id,
233                        RLMM_SNAPSHOT,
234                        &rlmm.display().to_string(),
235                        bytes,
236                    )
237                    .await?,
238                );
239            }
240            Err(BackupError::Io(error)) if error.kind() == ErrorKind::NotFound => {
241                absent.push(format!("{} is absent", rlmm.display()));
242            }
243            Err(error) => return Err(error),
244        }
245
246        match checkpoint {
247            Some((MetadataCheckpoint { path, .. }, bytes)) => {
248                notes.push(checkpoint_note(&bytes, captured_at_ms));
249                metadata_snapshot_sha256 = Some(Sha256Digest::of(&bytes));
250                artifacts.push(
251                    upload(
252                        &store,
253                        &id,
254                        METADATA_CHECKPOINT,
255                        &path.display().to_string(),
256                        bytes,
257                    )
258                    .await?,
259                );
260            }
261            None => absent.push(format!(
262                "{} holds no controller or observer metadata checkpoint",
263                log_dir.display()
264            )),
265        }
266    }
267
268    if let Some(bootstrap) = bootstrap_server {
269        let offsets = fetch_group_offsets(bootstrap, security.clone()).await?;
270        let bytes = serde_json::to_vec_pretty(&offsets).map_err(|source| BackupError::Json {
271            context: GROUP_OFFSETS.to_owned(),
272            source,
273        })?;
274        let group_offsets_sha256 = Some(Sha256Digest::of(&bytes));
275        artifacts.push(upload(&store, &id, GROUP_OFFSETS, bootstrap, bytes).await?);
276        if let Some(bytes) = capture_diskless_index(
277            &store,
278            bootstrap,
279            security.clone(),
280            signing,
281            CaptureDigests {
282                metadata_snapshot: metadata_snapshot_sha256,
283                rlmm_snapshot: rlmm_snapshot_sha256,
284                group_offsets: group_offsets_sha256,
285            },
286            events,
287        )
288        .await?
289        {
290            artifacts.push(upload(&store, &id, DISKLESS_WAL_INDEX, bootstrap, bytes).await?);
291        }
292    }
293
294    if artifacts.is_empty() {
295        return Err(BackupError::Io(Error::new(
296            ErrorKind::NotFound,
297            format!(
298                "captured nothing: {}",
299                if absent.is_empty() {
300                    "pass --log-dir, --bootstrap-server, or both".to_owned()
301                } else {
302                    absent.join("; ")
303                }
304            ),
305        )));
306    }
307
308    let manifest = Manifest {
309        version: MANIFEST_VERSION,
310        capture_id: id.clone(),
311        captured_at_ms,
312        log_dir: log_dir.map(|dir| dir.display().to_string()),
313        bootstrap_server: bootstrap_server.map(ToOwned::to_owned),
314        artifacts,
315    };
316    let encoded = serde_json::to_vec_pretty(&manifest).map_err(|source| BackupError::Json {
317        context: MANIFEST.to_owned(),
318        source,
319    })?;
320    check_capture_size(MANIFEST, encoded.len() as u64, MAX_MANIFEST_BYTES)?;
321    store.put_new(&capture_key(&id, MANIFEST), encoded).await?;
322
323    events.push(BackupEvent::Capture {
324        manifest,
325        notes,
326        missing: absent,
327    });
328    Ok(id)
329}
330
331/// Refuses a capture whose newest metadata checkpoint is the one `krabka
332/// format` wrote and holds no topic.
333///
334/// A plain format writes that checkpoint with the bootstrap records only. A
335/// restore from it brings back no topic configuration, and its report cannot
336/// say so, because the restore takes the checkpoint as the whole of the
337/// cluster's metadata. A capture is taken to be restored from, so the honest
338/// result is no capture.
339///
340/// A format that `krabka-restore` ran also writes its checkpoint at offset
341/// zero, but seeds the restored topics into it, so that one is captured.
342fn refuse_format_time_checkpoint(
343    checkpoint: &MetadataCheckpoint,
344    bytes: &[u8],
345) -> Result<(), BackupError> {
346    if !checkpoint.id.is_format_time() {
347        return Ok(());
348    }
349    let records = deserialize_metadata_snapshot(bytes).map_err(|error| {
350        BackupError::Integrity(format!(
351            "metadata checkpoint {}: {error}",
352            checkpoint.path.display()
353        ))
354    })?;
355    if holds_a_topic(&records) {
356        return Ok(());
357    }
358    Err(BackupError::StaleMetadata(format!(
359        "the newest metadata checkpoint is {}, the one `krabka format` wrote. It holds no \
360         topic, so a restore from it would bring back no topic configuration. The controller \
361         writes its next checkpoint after `metadata_max_snapshot_interval` (1 hour by \
362         default), or sooner once enough metadata is committed. Capture again after that, or \
363         lower `metadata_max_snapshot_interval`",
364        checkpoint.path.display()
365    )))
366}
367
368/// What a capture prints about the metadata checkpoint it took: how old its
369/// newest record is. A metadata change committed after the checkpoint, such as
370/// a topic config change, is not in the capture, and the age is how far back
371/// that window reaches.
372///
373/// The age comes from the checkpoint's own last-contained-record timestamp, not
374/// the file's modification time: a broker-only node writes the file when it
375/// fetches a snapshot, which can be long after the controller took it.
376fn checkpoint_note(bytes: &[u8], now_ms: u64) -> String {
377    let age = metadata_snapshot_last_contained_log_timestamp(bytes)
378        .ok()
379        .and_then(|timestamp| u64::try_from(timestamp).ok())
380        .filter(|timestamp| *timestamp > 0)
381        .map_or_else(
382            || "the age of its newest record is unknown".to_owned(),
383            |timestamp| {
384                format!(
385                    "its newest record is {}s old",
386                    now_ms.saturating_sub(timestamp) / 1000
387                )
388            },
389        );
390    format!(
391        "{METADATA_CHECKPOINT}: {age}. Metadata changes committed after it are not in \
392         this capture"
393    )
394}
395
396struct CaptureDigests {
397    metadata_snapshot: Option<Sha256Digest>,
398    rlmm_snapshot: Option<Sha256Digest>,
399    group_offsets: Option<Sha256Digest>,
400}
401
402async fn capture_diskless_index(
403    store: &Archive,
404    bootstrap: &str,
405    security: Option<ClientSecurity>,
406    signing: &CaptureSigningArgs,
407    digests: CaptureDigests,
408    events: &mut Vec<BackupEvent>,
409) -> Result<Option<Vec<u8>>, BackupError> {
410    const TOPIC: &str = "__diskless_wal_index";
411    const MAX_WAL_OBJECT_BYTES: u64 = 8 * 1024 * 1024 * 1024;
412    let mut admin = connect_admin(bootstrap, security.clone()).await?;
413    let index_metadata = admin
414        .metadata(&[TOPIC])
415        .await
416        .map_err(|error| BackupError::Cluster(format!("diskless WAL index Metadata: {error}")))?;
417    if !diskless_index_present(&index_metadata, TOPIC)? {
418        return Ok(None);
419    }
420    let metadata = admin
421        .metadata(&[])
422        .await
423        .map_err(|error| BackupError::Cluster(format!("Metadata: {error}")))?;
424    let topic_names = metadata
425        .topics
426        .iter()
427        .filter(|topic| topic.name != TOPIC)
428        .map(|topic| topic.name.as_str())
429        .collect::<Vec<_>>();
430    let resources = topic_names
431        .iter()
432        .map(|name| ConfigResource::topic(*name))
433        .collect::<Vec<_>>();
434    let configs = admin
435        .describe_configs(&resources, DescribeConfigsOptions::default())
436        .await
437        .map_err(|error| BackupError::Cluster(format!("DescribeConfigs: {error}")))?
438        .into_iter()
439        .map(|(resource, config)| {
440            // The client used to fail the whole call on a resource error, as
441            // this `Broker` error; keep that text.
442            let config = config.map_err(|error| {
443                let error = AdminError::Broker {
444                    api: "DescribeConfigs",
445                    code: error.code,
446                    name: error.name,
447                    message: error.message,
448                };
449                BackupError::Cluster(format!("DescribeConfigs: {error}"))
450            })?;
451            let overrides = config.dynamic_overrides(&resource);
452            Ok((resource.name, overrides))
453        })
454        .collect::<Result<BTreeMap<_, _>, BackupError>>()?;
455    let topics = diskless_topic_topology(&metadata, &configs, TOPIC)?;
456    let mut config = KafkaMetadataLogConfig::new(bootstrap);
457    TOPIC.clone_into(&mut config.topic);
458    "krabka-backup-diskless-capture".clone_into(&mut config.client_id);
459    config.security = security;
460    // Capture only reads. The broker owns provisioning and compaction policy,
461    // and Kafka refuses a client Produce to an internal topic, so the log
462    // opens without a producer and needs only READ and DESCRIBE on the index.
463    let log = KafkaMetadataEventLog::open_read_only(config)
464        .await
465        .map_err(|error| BackupError::Cluster(format!("diskless WAL index: {error}")))?;
466    let mut capture =
467        capture_projection(Arc::<KafkaMetadataEventLog>::clone(&log), &topics, now_ms())
468            .await
469            .map_err(BackupError::Integrity)?;
470    capture.metadata_snapshot_sha256 = digests.metadata_snapshot;
471    capture.rlmm_snapshot_sha256 = digests.rlmm_snapshot;
472    capture.group_offsets_sha256 = digests.group_offsets;
473    log.shutdown().await;
474    drop(log);
475    if let (Some(key_id), Some(key_path)) = (
476        signing.worm_signing_key_id.as_ref(),
477        signing.worm_signing_key.as_ref(),
478    ) {
479        let signer = FileEd25519Signer::from_pkcs8_file(key_path, key_id.clone())
480            .map_err(|error| BackupError::InvalidArgument(error.to_string()))?;
481        let keys = capture
482            .partitions
483            .iter()
484            .flat_map(|partition| &partition.ranges)
485            .map(|range| range.object_key.clone())
486            .collect::<BTreeSet<_>>();
487        let mut claims = Vec::with_capacity(keys.len());
488        for key in keys {
489            let bytes = store
490                .get_absolute_capped(&key, MAX_WAL_OBJECT_BYTES)
491                .await?;
492            claims.push(ObjectEntry {
493                suffix: Path::new(&key)
494                    .extension()
495                    .map_or_else(String::new, |extension| {
496                        format!(".{}", extension.to_string_lossy())
497                    }),
498                key,
499                size_bytes: bytes.len() as u64,
500                sha256: Sha256Digest::of(&bytes),
501                e_tag: None,
502                version_id: None,
503                create_precondition: false,
504            });
505        }
506        let head = capture
507            .seal(claims, &signer)
508            .map_err(BackupError::Integrity)?;
509        events.push(BackupEvent::DisklessSealed { head });
510    }
511    serde_json::to_vec_pretty(&capture)
512        .map(Some)
513        .map_err(|source| BackupError::Json {
514            context: DISKLESS_WAL_INDEX.to_owned(),
515            source,
516        })
517}
518
519fn diskless_topic_topology(
520    metadata: &TopicMetadata,
521    configs: &BTreeMap<String, BTreeMap<String, String>>,
522    index_topic: &str,
523) -> Result<HashMap<uuid::Uuid, (String, i32)>, BackupError> {
524    let mut topics = HashMap::new();
525    for topic in metadata
526        .topics
527        .iter()
528        .filter(|topic| topic.name != index_topic)
529    {
530        if let Some(error) = &topic.error {
531            return Err(BackupError::Cluster(format!(
532                "Metadata for `{}`: code={} ({})",
533                topic.name, error.code, error.name
534            )));
535        }
536        let config = configs.get(topic.name.as_str()).ok_or_else(|| {
537            BackupError::Cluster(format!(
538                "DescribeConfigs omitted metadata topic `{}`",
539                topic.name
540            ))
541        })?;
542        if config
543            .get("krabka.diskless")
544            .is_none_or(|value| value != "true")
545        {
546            continue;
547        }
548        let topic_id = topic.topic_id.ok_or_else(|| {
549            BackupError::Cluster(format!("diskless topic `{}` has no topic id", topic.name))
550        })?;
551        topics.insert(
552            uuid::Uuid::from_bytes(topic_id.into_bytes()),
553            (topic.name.clone(), topic.partition_count),
554        );
555    }
556    Ok(topics)
557}
558
559fn diskless_index_present(metadata: &TopicMetadata, name: &str) -> Result<bool, BackupError> {
560    let topic = metadata
561        .topics
562        .iter()
563        .find(|topic| topic.name == name)
564        .ok_or_else(|| {
565            BackupError::Cluster(format!(
566                "diskless WAL index Metadata omitted requested topic `{name}`"
567            ))
568        })?;
569    match &topic.error {
570        None => Ok(true),
571        Some(error) if error.code == 3 => Ok(false),
572        Some(error) => Err(BackupError::Cluster(format!(
573            "diskless WAL index Metadata: code={} ({}){}",
574            error.code,
575            error.name,
576            error
577                .message
578                .as_deref()
579                .map_or_default(|message| format!(" {message:?}"))
580        ))),
581    }
582}
583
584#[cfg(test)]
585mod diskless_tests {
586    use {
587        super::*,
588        assert2::check,
589        krabka_client_admin::{KafkaError, TopicMetadataEntry},
590    };
591
592    fn metadata(error: Option<KafkaError>) -> TopicMetadata {
593        TopicMetadata {
594            topics: vec![TopicMetadataEntry {
595                name: "__diskless_wal_index".into(),
596                topic_id: None,
597                partition_count: 0,
598                replication_factor: 0,
599                error,
600            }],
601            ..Default::default()
602        }
603    }
604
605    #[test]
606    fn diskless_index_discovery_distinguishes_absence_from_denial() {
607        check!(diskless_index_present(&metadata(None), "__diskless_wal_index").unwrap());
608        check!(
609            !diskless_index_present(
610                &metadata(Some(KafkaError {
611                    code: 3,
612                    name: "UNKNOWN_TOPIC_OR_PARTITION",
613                    message: None,
614                })),
615                "__diskless_wal_index",
616            )
617            .unwrap()
618        );
619        check!(
620            diskless_index_present(
621                &metadata(Some(KafkaError {
622                    code: 29,
623                    name: "TOPIC_AUTHORIZATION_FAILED",
624                    message: None,
625                })),
626                "__diskless_wal_index",
627            )
628            .is_err()
629        );
630        check!(diskless_index_present(&TopicMetadata::default(), "__diskless_wal_index").is_err());
631    }
632
633    #[test]
634    fn diskless_topology_keeps_every_partition_including_empty_ones() {
635        let metadata = TopicMetadata {
636            topics: vec![TopicMetadataEntry {
637                name: "orders".into(),
638                topic_id: Some(uuid::Uuid::from_u128(7)),
639                partition_count: 3,
640                replication_factor: 1,
641                error: None,
642            }],
643            ..Default::default()
644        };
645        let configs = BTreeMap::from([(
646            "orders".to_owned(),
647            BTreeMap::from([("krabka.diskless".into(), "true".into())]),
648        )]);
649
650        check!(
651            diskless_topic_topology(&metadata, &configs, "__diskless_wal_index").unwrap()
652                == HashMap::from([(uuid::Uuid::from_u128(7), ("orders".to_owned(), 3))])
653        );
654    }
655}
656
657/// Put one artifact into the capture and record what was written.
658async fn upload(
659    store: &Archive,
660    id: &str,
661    name: &str,
662    source: &str,
663    bytes: Vec<u8>,
664) -> Result<Artifact, BackupError> {
665    upload_capped(store, id, name, source, bytes, artifact_byte_limit(name)).await
666}
667
668async fn upload_capped(
669    store: &Archive,
670    id: &str,
671    name: &str,
672    source: &str,
673    bytes: Vec<u8>,
674    max_bytes: u64,
675) -> Result<Artifact, BackupError> {
676    check_capture_size(name, bytes.len() as u64, max_bytes)?;
677    let artifact = Artifact {
678        name: name.to_owned(),
679        source: source.to_owned(),
680        size_bytes: bytes.len() as u64,
681        sha256: sha256_hex(&bytes),
682    };
683    store.put_new(&capture_key(id, name), bytes).await?;
684    Ok(artifact)
685}
686
687/// Read every capture in the archive, newest last.
688///
689/// # Errors
690///
691/// The report result returns the archive's own error when listing fails. Per-manifest read errors are report events.
692pub async fn list(archive: &ArchiveArgs) -> OperationReport<Vec<String>> {
693    let mut events = Vec::new();
694    let result = list_inner(archive, &mut events).await;
695    OperationReport { result, events }
696}
697
698async fn list_inner(
699    archive: &ArchiveArgs,
700    events: &mut Vec<BackupEvent>,
701) -> Result<Vec<String>, BackupError> {
702    let store = Archive::open(archive)?;
703    let ids = store.child_directories(CAPTURE_ROOT).await?;
704    if ids.is_empty() {
705        events.push(BackupEvent::EmptyList);
706    }
707    for id in &ids {
708        events.push(BackupEvent::Listed {
709            id: id.clone(),
710            manifest: read_manifest(&store, id).await,
711        });
712    }
713    Ok(ids.into_iter().collect())
714}
715
716/// Re-read a capture and check every artifact against its recorded digest.
717///
718/// # Errors
719///
720/// The report result returns [`BackupError::Integrity`] when an artifact's bytes are not the
721/// bytes the manifest recorded, and the archive's own error when the capture
722/// cannot be read at all.
723pub async fn verify(capture: &str, archive: &ArchiveArgs) -> OperationReport<()> {
724    let mut events = Vec::new();
725    let result = verify_inner(capture, archive, &mut events).await;
726    OperationReport { result, events }
727}
728
729async fn verify_inner(
730    capture: &str,
731    archive: &ArchiveArgs,
732    events: &mut Vec<BackupEvent>,
733) -> Result<(), BackupError> {
734    let OpenedCapture {
735        store,
736        id,
737        manifest,
738    } = open_capture(archive, capture).await?;
739
740    let mut problems: Vec<String> = Vec::new();
741    for artifact in &manifest.artifacts {
742        let bytes = store
743            .get_capped(
744                &capture_key(&id, &artifact.name),
745                artifact_byte_limit(&artifact.name),
746            )
747            .await?;
748        match artifact_problem(artifact, &bytes) {
749            Some(problem) => problems.push(problem),
750            None => events.push(BackupEvent::ArtifactVerified {
751                name: artifact.name.clone(),
752                size_bytes: artifact.size_bytes,
753            }),
754        }
755    }
756    if problems.is_empty() {
757        events.push(BackupEvent::CaptureVerified {
758            id,
759            artifacts: manifest.artifacts.len(),
760        });
761        return Ok(());
762    }
763    Err(BackupError::Integrity(format!(
764        "capture {id}: {}",
765        problems.join("; ")
766    )))
767}
768
769/// Commit a capture's group offsets into a restored cluster.
770///
771/// # Errors
772///
773/// The report result returns [`BackupError::Integrity`] when the captured offsets do not match
774/// their recorded digest, [`BackupError::Cluster`] when the cluster refuses a
775/// commit, and the archive's own error when the capture cannot be read.
776pub async fn restore_offsets(
777    capture: &str,
778    bootstrap_server: &str,
779    dry_run: bool,
780    archive: &ArchiveArgs,
781) -> OperationReport<usize> {
782    let mut events = Vec::new();
783    let result = restore_offsets_secured_inner(
784        capture,
785        bootstrap_server,
786        None,
787        &CaptureTrustArgs::default(),
788        dry_run,
789        archive,
790        &mut events,
791    )
792    .await;
793    OperationReport { result, events }
794}
795
796/// Load command-config security and restore one capture's group offsets.
797///
798/// # Errors
799///
800/// The report result returns the same errors as [`restore_offsets`], plus command-config read or
801/// parse failures.
802pub async fn restore_offsets_configured(
803    capture: &str,
804    bootstrap_server: &str,
805    security: &SecurityArgs,
806    trust: &CaptureTrustArgs,
807    dry_run: bool,
808    archive: &ArchiveArgs,
809) -> OperationReport<usize> {
810    let mut events = Vec::new();
811    let result = async {
812        restore_offsets_secured_inner(
813            capture,
814            bootstrap_server,
815            security.load().await?,
816            trust,
817            dry_run,
818            archive,
819            &mut events,
820        )
821        .await
822    }
823    .await;
824    OperationReport { result, events }
825}
826
827async fn restore_offsets_secured_inner(
828    capture: &str,
829    bootstrap_server: &str,
830    security: Option<ClientSecurity>,
831    trust: &CaptureTrustArgs,
832    dry_run: bool,
833    archive: &ArchiveArgs,
834    events: &mut Vec<BackupEvent>,
835) -> Result<usize, BackupError> {
836    let OpenedCapture {
837        store,
838        id,
839        manifest,
840    } = open_capture(archive, capture).await?;
841    let recorded = manifest.artifact(GROUP_OFFSETS).ok_or_else(|| {
842        BackupError::NoSuchCapture(format!("capture {id} holds no {GROUP_OFFSETS}"))
843    })?;
844    let bytes = store
845        .get_capped(&capture_key(&id, GROUP_OFFSETS), MAX_GROUP_OFFSETS_BYTES)
846        .await?;
847    if let Some(problem) = artifact_problem(recorded, &bytes) {
848        return Err(BackupError::Integrity(format!("capture {id}: {problem}")));
849    }
850    verify_offsets_boundary(&store, &id, &manifest, &bytes, trust).await?;
851    let offsets = GroupOffsetsFile::from_slice(&bytes, &capture_key(&id, GROUP_OFFSETS))?;
852
853    if dry_run {
854        for group in &offsets.groups {
855            for offset in &group.offsets {
856                events.push(BackupEvent::WouldCommit {
857                    group: group.group.clone(),
858                    topic: offset.topic.clone(),
859                    partition: offset.partition,
860                    offset: offset.offset,
861                });
862            }
863        }
864        return Ok(offsets.offset_count());
865    }
866
867    if offsets.offset_count() == 0 {
868        return Ok(0);
869    }
870    let mut admin = connect_admin(bootstrap_server, security).await?;
871    let mut committed = 0;
872    for group in &offsets.groups {
873        if group.offsets.is_empty() {
874            continue;
875        }
876        let result = admin
877            .alter_consumer_group_offsets(&group.group, &admin_offsets(group))
878            .await;
879        committed += commit_outcome(group, result)?;
880        events.push(BackupEvent::OffsetsCommitted {
881            group: group.group.clone(),
882            offsets: group.offsets.len(),
883        });
884    }
885    Ok(committed)
886}
887
888async fn verify_offsets_boundary(
889    store: &Archive,
890    id: &str,
891    manifest: &Manifest,
892    offsets: &[u8],
893    trust: &CaptureTrustArgs,
894) -> Result<(), BackupError> {
895    let configured = trust.worm_key_id.is_some()
896        || trust.worm_public_key.is_some()
897        || trust.worm_expect_head.is_some();
898    let Some(recorded) = manifest.artifact(DISKLESS_WAL_INDEX) else {
899        if configured {
900            return Err(BackupError::Integrity(format!(
901                "capture {id} holds no signed diskless boundary for group offsets"
902            )));
903        }
904        return Ok(());
905    };
906    let bytes = store
907        .get_capped(
908            &capture_key(id, DISKLESS_WAL_INDEX),
909            MAX_DISKLESS_INDEX_BYTES,
910        )
911        .await?;
912    if let Some(problem) = artifact_problem(recorded, &bytes) {
913        return Err(BackupError::Integrity(format!("capture {id}: {problem}")));
914    }
915    let capture = DisklessWalCapture::from_slice(&bytes).map_err(BackupError::Integrity)?;
916    if capture.authentication.is_none() && !configured {
917        return Ok(());
918    }
919    let key_id = trust.worm_key_id.as_ref().ok_or_else(|| {
920        BackupError::Integrity("signed offset restore requires --worm-key-id".to_owned())
921    })?;
922    let key_path = trust.worm_public_key.as_ref().ok_or_else(|| {
923        BackupError::Integrity("signed offset restore requires --worm-public-key".to_owned())
924    })?;
925    let expected_head = trust.worm_expect_head.as_deref().ok_or_else(|| {
926        BackupError::Integrity("signed offset restore requires --worm-expect-head".to_owned())
927    })?;
928    let public_key = fs::read(key_path).await?;
929    let trusted = krabka_remote_storage::TrustedManifestKeys::single(key_id.clone(), public_key);
930    capture
931        .authenticate(&trusted, Some(expected_head))
932        .map_err(BackupError::Integrity)?;
933    if capture.group_offsets_sha256 != Some(Sha256Digest::of(offsets)) {
934        return Err(BackupError::Integrity(
935            "group offsets do not match the signed diskless capture boundary".to_owned(),
936        ));
937    }
938    Ok(())
939}
940
941/// Read every group's committed offsets from a live cluster.
942async fn fetch_group_offsets(
943    bootstrap_server: &str,
944    security: Option<ClientSecurity>,
945) -> Result<GroupOffsetsFile, BackupError> {
946    let mut admin = connect_admin(bootstrap_server, security).await?;
947    let groups = admin
948        .list_groups(&ListGroupsOptions::default())
949        .await
950        .map_err(|error| BackupError::Cluster(format!("ListGroups: {error}")))?
951        .all()
952        .map_err(|failure| {
953            BackupError::Cluster(format!(
954                "ListGroups on broker {}: {} ({})",
955                failure.node_id, failure.error.name, failure.error.code
956            ))
957        })?;
958    let mut captured = GroupOffsetsFile::default();
959    for group in groups.into_iter().map(|listing| listing.group_id) {
960        let fetched = admin
961            .list_consumer_group_offsets(&group)
962            .await
963            .map_err(|error| BackupError::Cluster(format!("OffsetFetch for {group}: {error}")))?;
964        if fetched.is_empty() {
965            continue;
966        }
967        captured.groups.push(group_offsets(&group, &fetched));
968    }
969    Ok(captured)
970}
971
972async fn connect_admin(
973    bootstrap_server: &str,
974    security: Option<ClientSecurity>,
975) -> Result<AdminClient, BackupError> {
976    AdminClient::connect_secured(&[bootstrap_server.to_owned()], security)
977        .await
978        .map_err(|error| BackupError::Cluster(format!("cannot reach {bootstrap_server}: {error}")))
979}
980
981/// An opened archive, the id of the capture selected in it, and that capture's manifest.
982struct OpenedCapture {
983    store: Archive,
984    id: String,
985    manifest: Manifest,
986}
987
988/// Resolve a capture selector and load its manifest from the opened archive.
989async fn open_capture(archive: &ArchiveArgs, capture: &str) -> Result<OpenedCapture, BackupError> {
990    let store = Archive::open(archive)?;
991    let id = resolve_capture(&store, capture).await?;
992    let manifest = read_manifest(&store, &id).await?;
993    Ok(OpenedCapture {
994        store,
995        id,
996        manifest,
997    })
998}
999
1000/// Select the newest committed capture for `latest`, or check that a named directory exists.
1001async fn resolve_capture(store: &Archive, capture: &str) -> Result<String, BackupError> {
1002    let ids = store.child_directories(CAPTURE_ROOT).await?;
1003    if capture == LATEST {
1004        for id in ids.into_iter().rev() {
1005            match read_manifest(store, &id).await {
1006                Ok(_) => return Ok(id),
1007                // Artifacts can outlive an interrupted capture; its manifest
1008                // is published last and commits the completed capture.
1009                Err(BackupError::ObjectStore(krabka_object_store::ObjectStoreError::NotFound(
1010                    _,
1011                ))) => {}
1012                Err(error) => return Err(error),
1013            }
1014        }
1015        return Err(BackupError::NoSuchCapture(format!(
1016            "the archive holds no completed capture under {CAPTURE_ROOT}"
1017        )));
1018    }
1019    if ids.contains(capture) {
1020        return Ok(capture.to_owned());
1021    }
1022    Err(BackupError::NoSuchCapture(format!(
1023        "the archive holds no capture {capture}"
1024    )))
1025}
1026
1027async fn read_manifest(store: &Archive, id: &str) -> Result<Manifest, BackupError> {
1028    let key = capture_key(id, MANIFEST);
1029    let bytes = store.get_capped(&key, MAX_MANIFEST_BYTES).await?;
1030    Manifest::from_slice(&bytes, &key)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use {
1036        super::{
1037            BackupError, CAPTURE_ROOT, DISKLESS_WAL_INDEX, DisklessWalCapture, FileEd25519Signer,
1038            GROUP_OFFSETS, MANIFEST, MAX_SNAPSHOT_BYTES, METADATA_CHECKPOINT, RLMM_SNAPSHOT,
1039            RLMM_SNAPSHOT_RELATIVE, Sha256Digest, artifact_byte_limit, capture, check_capture_size,
1040            checkpoint_note, list, newest_metadata_checkpoint, restore_offsets, upload_capped,
1041            verify, verify_offsets_boundary,
1042        },
1043        crate::{
1044            archive::{Archive, ArchiveArgs},
1045            capture::capture_key,
1046            cli::CaptureTrustArgs,
1047            manifest::{Artifact, MANIFEST_VERSION, Manifest, sha256_hex},
1048            offsets::{CommittedOffset, GROUP_OFFSETS_VERSION, GroupOffsets, GroupOffsetsFile},
1049            report::BackupEvent,
1050        },
1051        assert2::{assert, check},
1052        krabka_ids::PartitionIndex,
1053        krabka_metadata::{MetadataImage, MetadataRecord, NodeId, PartitionRecord, TopicRecord},
1054        std::{
1055            fs,
1056            fs::OpenOptions,
1057            io::ErrorKind,
1058            net::TcpListener,
1059            path::{Path, PathBuf},
1060        },
1061    };
1062
1063    /// The RLMM snapshot bytes a fixture node holds. The capture copies bytes
1064    /// and decodes nothing, so any content proves the same thing.
1065    const RLMM_BYTES: &[u8] = b"rlmm snapshot bytes";
1066
1067    /// The newest checkpoint's bytes.
1068    const CHECKPOINT_BYTES: &[u8] = b"the newest controller checkpoint";
1069
1070    fn archive_args(root: &Path) -> ArchiveArgs {
1071        ArchiveArgs {
1072            local: Some(root.to_path_buf()),
1073            ..ArchiveArgs::default()
1074        }
1075    }
1076
1077    krabka_macros::snapshot_node_fixture!(node_with_rlmm);
1078
1079    #[yare::parameterized(
1080        manifest = { MANIFEST, 8 * 1024 * 1024 },
1081        group_offsets = { GROUP_OFFSETS, 64 * 1024 * 1024 },
1082        metadata_checkpoint = { METADATA_CHECKPOINT, 512 * 1024 * 1024 },
1083        rlmm_snapshot = { RLMM_SNAPSHOT, 512 * 1024 * 1024 },
1084        diskless_wal_index = { DISKLESS_WAL_INDEX, 256 * 1024 * 1024 },
1085    )]
1086    fn capture_size_limits_match_readers_at_the_boundary(name: &str, expected: u64) {
1087        let limit = artifact_byte_limit(name);
1088        check!(limit == expected);
1089        check!(check_capture_size(name, limit, limit).is_ok());
1090        check!(check_capture_size(name, limit + 1, limit).is_err());
1091        check!(check_capture_size(name, u64::MAX, limit).is_err());
1092    }
1093
1094    #[yare::parameterized(
1095        rlmm_at_limit = { RLMM_SNAPSHOT, 8usize, true },
1096        rlmm_over_limit = { RLMM_SNAPSHOT, 9usize, false },
1097        metadata_at_limit = { METADATA_CHECKPOINT, 8usize, true },
1098        metadata_over_limit = { METADATA_CHECKPOINT, 9usize, false },
1099        offsets_at_limit = { GROUP_OFFSETS, 8usize, true },
1100        offsets_over_limit = { GROUP_OFFSETS, 9usize, false },
1101        wal_index_at_limit = { DISKLESS_WAL_INDEX, 8usize, true },
1102        wal_index_over_limit = { DISKLESS_WAL_INDEX, 9usize, false },
1103    )]
1104    #[test_macro(tokio::test)]
1105    async fn capture_upload_caps_accept_equality_and_reject_overflow_before_put(
1106        name: &str,
1107        size: usize,
1108        expected_success: bool,
1109    ) {
1110        let ArchiveFixture {
1111            root: _root, store, ..
1112        } = archive_fixture();
1113        let id = "0000000000000042";
1114        // Earlier artifacts may remain orphaned after a later failure.
1115        store
1116            .put_new(&capture_key(id, "earlier"), b"valid".to_vec())
1117            .await
1118            .unwrap();
1119        let result = upload_capped(&store, id, name, "node", vec![b'x'; size], 8).await;
1120        if expected_success {
1121            check!(
1122                result.unwrap()
1123                    == Artifact {
1124                        name: name.to_owned(),
1125                        source: "node".to_owned(),
1126                        size_bytes: 8,
1127                        sha256: sha256_hex(&[b'x'; 8]),
1128                    }
1129            );
1130            check!(store.get(&capture_key(id, name)).await.unwrap() == vec![b'x'; size]);
1131        } else {
1132            let error = result.unwrap_err();
1133            check!(
1134                matches!(&error, BackupError::Io(error) if error.kind() == ErrorKind::InvalidData)
1135            );
1136            check!(error.to_string().contains(name));
1137            check!(matches!(
1138                store.get(&capture_key(id, name)).await,
1139                Err(BackupError::ObjectStore(
1140                    krabka_object_store::ObjectStoreError::NotFound(_)
1141                ))
1142            ));
1143            check!(matches!(
1144                store.get(&capture_key(id, MANIFEST)).await,
1145                Err(BackupError::ObjectStore(
1146                    krabka_object_store::ObjectStoreError::NotFound(_)
1147                ))
1148            ));
1149            check!(store.get(&capture_key(id, "earlier")).await.unwrap() == b"valid");
1150        }
1151    }
1152
1153    #[yare::parameterized(
1154        rlmm = { RLMM_SNAPSHOT },
1155        metadata_checkpoint = { METADATA_CHECKPOINT },
1156    )]
1157    #[test_macro(tokio::test)]
1158    async fn capture_rejects_oversized_local_snapshots_without_committing(name: &str) {
1159        let node = node_with_both_inputs();
1160        let path = if name == RLMM_SNAPSHOT {
1161            node.path().join(RLMM_SNAPSHOT_RELATIVE)
1162        } else {
1163            newest_metadata_checkpoint(node.path()).unwrap().path
1164        };
1165        OpenOptions::new()
1166            .write(true)
1167            .open(path)
1168            .unwrap()
1169            .set_len(MAX_SNAPSHOT_BYTES + 1)
1170            .unwrap();
1171        let ArchiveFixture {
1172            root: _root,
1173            args,
1174            store,
1175        } = archive_fixture();
1176        let error = capture(Some(node.path()), None, &args)
1177            .await
1178            .result
1179            .unwrap_err();
1180        check!(error.to_string().contains(name));
1181        // Both local snapshots are read before the first upload, so either
1182        // refusal leaves the archive untouched.
1183        check!(
1184            store
1185                .child_directories(CAPTURE_ROOT)
1186                .await
1187                .unwrap()
1188                .is_empty()
1189        );
1190    }
1191
1192    #[tokio::test]
1193    async fn concurrent_captures_at_the_same_timestamp_keep_both_nodes_bytes() {
1194        let first = node_with_rlmm();
1195        let second = node_with_rlmm();
1196        let snapshot = second.path().join(super::RLMM_SNAPSHOT_RELATIVE);
1197        fs::write(snapshot, b"second node snapshot").unwrap();
1198        let ArchiveFixture {
1199            root: _root,
1200            args: archive,
1201            store,
1202        } = archive_fixture();
1203        let signing = super::CaptureSigningArgs::default();
1204        let (left, right) = tokio::join!(
1205            super::capture_secured_at(Some(first.path()), None, None, &signing, &archive, 42),
1206            super::capture_secured_at(Some(second.path()), None, None, &signing, &archive, 42),
1207        );
1208        let left = left.result.unwrap();
1209        let right = right.result.unwrap();
1210        check!(left != right);
1211        check!(list(&archive).await.result.unwrap().len() == 2);
1212        for (id, expected) in [
1213            (&left, RLMM_BYTES),
1214            (&right, b"second node snapshot".as_slice()),
1215        ] {
1216            verify(id, &archive).await.result.unwrap();
1217            check!(store.get(&capture_key(id, RLMM_SNAPSHOT)).await.unwrap() == expected);
1218            check!(capture_manifest(&store, id).await.captured_at_ms == 42);
1219        }
1220    }
1221
1222    #[tokio::test]
1223    async fn latest_ignores_newer_capture_artifacts_without_a_committed_manifest() {
1224        let ArchiveFixture {
1225            root: _root,
1226            args,
1227            store,
1228        } = archive_fixture();
1229        let complete = "0000000000000001";
1230        let orphan = "0000000000000002-0123456789abcdef0123456789abcdef";
1231        write_offsets_capture(&store, complete, b"offsets", b"offsets").await;
1232        store
1233            .put(&capture_key(orphan, GROUP_OFFSETS), b"orphan".to_vec())
1234            .await
1235            .unwrap();
1236        check!(super::resolve_capture(&store, "latest").await.unwrap() == complete);
1237        check!(verify("latest", &args).await.result.is_ok());
1238        check!(super::resolve_capture(&store, orphan).await.unwrap() == orphan);
1239        check!(matches!(
1240            verify(orphan, &args).await.result,
1241            Err(BackupError::ObjectStore(
1242                krabka_object_store::ObjectStoreError::NotFound(_)
1243            ))
1244        ));
1245    }
1246
1247    #[tokio::test]
1248    async fn document_reads_reject_independent_limits_before_buffering() {
1249        let ArchiveFixture {
1250            root,
1251            args: archive,
1252            store,
1253        } = archive_fixture();
1254        let id = "0000000000000001";
1255        let key = capture_key(id, MANIFEST);
1256        store.put(&key, b"{}".to_vec()).await.unwrap();
1257        set_file_len(root.path(), &key, super::MAX_MANIFEST_BYTES + 1);
1258        let error = super::read_manifest(&store, id).await.unwrap_err();
1259        check!(
1260            matches!(error, BackupError::ObjectStore(krabka_object_store::ObjectStoreError::TooLarge { max_bytes, .. }) if max_bytes == super::MAX_MANIFEST_BYTES)
1261        );
1262
1263        write_offsets_capture(&store, id, b"offsets", b"offsets").await;
1264        let key = capture_key(id, GROUP_OFFSETS);
1265        set_file_len(root.path(), &key, super::MAX_GROUP_OFFSETS_BYTES + 1);
1266        let error = verify(id, &archive).await.result.unwrap_err();
1267        check!(
1268            matches!(error, BackupError::ObjectStore(krabka_object_store::ObjectStoreError::TooLarge { max_bytes, .. }) if max_bytes == super::MAX_GROUP_OFFSETS_BYTES)
1269        );
1270    }
1271
1272    /// A temporary archive root, the arguments that open it, and the opened archive.
1273    struct ArchiveFixture {
1274        root: tempfile::TempDir,
1275        args: ArchiveArgs,
1276        store: Archive,
1277    }
1278
1279    fn archive_fixture() -> ArchiveFixture {
1280        let archive_root = tempfile::tempdir().expect("archive root");
1281        let args = archive_args(archive_root.path());
1282        let store = Archive::open(&args).expect("open the archive");
1283        ArchiveFixture {
1284            root: archive_root,
1285            args,
1286            store,
1287        }
1288    }
1289
1290    fn set_file_len(root: &Path, key: &str, len: u64) {
1291        OpenOptions::new()
1292            .write(true)
1293            .open(root.join(key))
1294            .unwrap()
1295            .set_len(len)
1296            .unwrap();
1297    }
1298
1299    /// A node directory, a fresh archive root, the arguments that open it, and the id of the capture of the node taken into it.
1300    struct CapturedNode {
1301        node: tempfile::TempDir,
1302        archive_root: tempfile::TempDir,
1303        args: ArchiveArgs,
1304        id: String,
1305    }
1306
1307    async fn captured_node() -> CapturedNode {
1308        let node = node_with_both_inputs();
1309        let archive_root = tempfile::tempdir().expect("archive root");
1310        let args = archive_args(archive_root.path());
1311        let id = capture(Some(node.path()), None, &args)
1312            .await
1313            .result
1314            .expect("capture the node");
1315        CapturedNode {
1316            node,
1317            archive_root,
1318            args,
1319            id,
1320        }
1321    }
1322
1323    async fn capture_manifest(store: &Archive, id: &str) -> Manifest {
1324        serde_json::from_slice(
1325            &store
1326                .get(&capture_key(id, MANIFEST))
1327                .await
1328                .expect("read the manifest"),
1329        )
1330        .expect("decode the manifest")
1331    }
1332
1333    fn check_unreachable(error: &BackupError, address: &str) {
1334        assert!(let BackupError::Cluster(_) = error, "got: {error}");
1335        check!(error.to_string().contains(address), "got: {error}");
1336    }
1337
1338    /// A log directory holding both of the files a capture takes off a node.
1339    fn node_with_both_inputs() -> tempfile::TempDir {
1340        let log_dir = node_with_rlmm();
1341        write_checkpoint(log_dir.path());
1342        log_dir
1343    }
1344
1345    /// The newest controller checkpoint, in the directory a controller writes.
1346    fn write_checkpoint(log_dir: &Path) {
1347        let metadata = log_dir.join("__cluster_metadata-0");
1348        fs::create_dir_all(&metadata).expect("create the metadata dir");
1349        fs::write(
1350            metadata.join("00000000000000000042-0000000001.checkpoint"),
1351            CHECKPOINT_BYTES,
1352        )
1353        .expect("write the newest checkpoint");
1354    }
1355
1356    /// An address on the loopback interface that nothing is listening on, so
1357    /// a connection attempt is refused rather than timing out.
1358    fn closed_address() -> String {
1359        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a probe listener");
1360        let port = listener.local_addr().expect("the probe's address").port();
1361        drop(listener);
1362        format!("127.0.0.1:{port}")
1363    }
1364
1365    /// Write a capture that no [`capture`] call produced: a manifest naming one
1366    /// group-offsets artifact, and whatever bytes the caller wants under it.
1367    /// It is how the read paths are driven over inputs a healthy capture cannot
1368    /// leave behind.
1369    async fn write_offsets_capture(store: &Archive, id: &str, bytes: &[u8], recorded: &[u8]) {
1370        let manifest = Manifest {
1371            version: MANIFEST_VERSION,
1372            capture_id: id.to_owned(),
1373            captured_at_ms: 1_700_000_000_000,
1374            log_dir: None,
1375            bootstrap_server: Some("broker-1:9092".to_owned()),
1376            artifacts: vec![Artifact {
1377                name: GROUP_OFFSETS.to_owned(),
1378                source: "broker-1:9092".to_owned(),
1379                size_bytes: recorded.len() as u64,
1380                sha256: sha256_hex(recorded),
1381            }],
1382        };
1383        store
1384            .put(
1385                &capture_key(id, MANIFEST),
1386                serde_json::to_vec(&manifest).expect("encode the manifest"),
1387            )
1388            .await
1389            .expect("write the manifest");
1390        store
1391            .put(&capture_key(id, GROUP_OFFSETS), bytes.to_vec())
1392            .await
1393            .expect("write the offsets");
1394    }
1395
1396    /// One group with one committed offset, as JSON bytes.
1397    fn offsets_json() -> Vec<u8> {
1398        serde_json::to_vec(&GroupOffsetsFile {
1399            version: GROUP_OFFSETS_VERSION,
1400            groups: vec![GroupOffsets {
1401                group: "analytics".to_owned(),
1402                offsets: vec![CommittedOffset {
1403                    topic: "orders".to_owned(),
1404                    partition: 0,
1405                    offset: 42,
1406                }],
1407            }],
1408        })
1409        .expect("encode the offsets")
1410    }
1411
1412    #[tokio::test]
1413    async fn signed_capture_boundary_rejects_forged_group_offsets() {
1414        const PKCS8: &[u8] = &[
1415            0x30, 0x53, 0x02, 0x01, 0x01, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
1416            0x04, 0x20, 0x10, 0xb4, 0x98, 0x61, 0xa2, 0x6e, 0xcb, 0x9b, 0x94, 0x33, 0x41, 0xa3,
1417            0x7f, 0xb7, 0x7d, 0x80, 0x95, 0x6a, 0xa7, 0xda, 0x95, 0x25, 0x62, 0xc6, 0x9b, 0x61,
1418            0xfd, 0xb6, 0x63, 0x77, 0xe5, 0xba, 0xa1, 0x23, 0x03, 0x21, 0x00, 0x13, 0x57, 0x2a,
1419            0x6e, 0xa1, 0xd1, 0xe1, 0x9d, 0x61, 0x77, 0x0e, 0xcb, 0xe3, 0x12, 0x91, 0xe9, 0xac,
1420            0x86, 0x0f, 0x0b, 0x93, 0xf5, 0x60, 0x09, 0xfe, 0xa8, 0xcc, 0xc9, 0x0e, 0xfe, 0x2a,
1421            0x87,
1422        ];
1423        let offsets = offsets_json();
1424        let signer = FileEd25519Signer::from_pkcs8_bytes(PKCS8, "capture-key".into()).unwrap();
1425        let mut capture = DisklessWalCapture {
1426            format_version: DisklessWalCapture::FORMAT_VERSION,
1427            captured_at_ms: 42,
1428            source_cutoffs: Vec::new(),
1429            partitions: Vec::new(),
1430            metadata_snapshot_sha256: None,
1431            rlmm_snapshot_sha256: None,
1432            group_offsets_sha256: Some(Sha256Digest::of(&offsets)),
1433            authentication: None,
1434        };
1435        let head = capture.seal(Vec::new(), &signer).unwrap();
1436        let capture_bytes = serde_json::to_vec(&capture).unwrap();
1437        let root = tempfile::tempdir().unwrap();
1438        let public_key = root.path().join("capture.pub");
1439        fs::write(&public_key, signer.public_key()).unwrap();
1440        let archive = Archive::open(&archive_args(root.path())).unwrap();
1441        let id = "0000000000000042";
1442        archive
1443            .put(&capture_key(id, DISKLESS_WAL_INDEX), capture_bytes.clone())
1444            .await
1445            .unwrap();
1446        let manifest = Manifest {
1447            version: MANIFEST_VERSION,
1448            capture_id: id.into(),
1449            captured_at_ms: 42,
1450            log_dir: None,
1451            bootstrap_server: Some("broker:9092".into()),
1452            artifacts: vec![
1453                Artifact {
1454                    name: GROUP_OFFSETS.into(),
1455                    source: "broker:9092".into(),
1456                    size_bytes: offsets.len() as u64,
1457                    sha256: sha256_hex(&offsets),
1458                },
1459                Artifact {
1460                    name: DISKLESS_WAL_INDEX.into(),
1461                    source: "broker:9092".into(),
1462                    size_bytes: capture_bytes.len() as u64,
1463                    sha256: sha256_hex(&capture_bytes),
1464                },
1465            ],
1466        };
1467        let trust = CaptureTrustArgs {
1468            worm_key_id: Some("capture-key".into()),
1469            worm_public_key: Some(public_key),
1470            worm_expect_head: Some(head.to_string()),
1471        };
1472
1473        verify_offsets_boundary(&archive, id, &manifest, &offsets, &trust)
1474            .await
1475            .expect("authentic offsets");
1476        check!(
1477            verify_offsets_boundary(&archive, id, &manifest, b"forged", &trust)
1478                .await
1479                .is_err()
1480        );
1481    }
1482
1483    #[tokio::test(flavor = "multi_thread")]
1484    async fn a_capture_records_both_snapshots_with_their_digests() {
1485        let CapturedNode {
1486            node: _node,
1487            archive_root: _archive_root,
1488            args,
1489            id,
1490        } = captured_node().await;
1491
1492        let store = Archive::open(&args).expect("open the archive");
1493        let manifest = capture_manifest(&store, &id).await;
1494        check!(manifest.capture_id == id);
1495        check!(manifest.bootstrap_server == None);
1496        check!(
1497            manifest.artifact(RLMM_SNAPSHOT).map(|a| a.sha256.clone())
1498                == Some(sha256_hex(RLMM_BYTES))
1499        );
1500        check!(
1501            manifest
1502                .artifact(METADATA_CHECKPOINT)
1503                .map(|a| a.sha256.clone())
1504                == Some(sha256_hex(CHECKPOINT_BYTES))
1505        );
1506        check!(manifest.artifact(GROUP_OFFSETS) == None);
1507    }
1508
1509    /// The name `krabka format` gives its offset-zero checkpoint.
1510    const FORMAT_TIME_CHECKPOINT: &str = "00000000000000000000-0000000000.checkpoint";
1511
1512    krabka_macros::single_replica_partition_fixture!(single_replica_partition);
1513
1514    /// The records `krabka-restore` seeds for one restored topic.
1515    fn restored_topic() -> Vec<MetadataRecord> {
1516        vec![
1517            MetadataRecord::V1Topic(TopicRecord {
1518                name: "ledger".to_owned(),
1519                topic_id: uuid::Uuid::from_u128(1),
1520                partitions: 1,
1521                replication_factor: 1,
1522            }),
1523            MetadataRecord::V1Partition(PartitionRecord {
1524                directories: vec![uuid::Uuid::nil()],
1525                ..single_replica_partition("ledger", PartitionIndex(0), NodeId(1))
1526            }),
1527        ]
1528    }
1529
1530    /// A node log directory that `krabka format` wrote with `seeded` records,
1531    /// its offset-zero checkpoint moved into `subdir` and an RLMM snapshot
1532    /// beside it. The returned guard owns the parent directory.
1533    async fn formatted_node(
1534        seeded: Vec<MetadataRecord>,
1535        subdir: &str,
1536    ) -> (tempfile::TempDir, PathBuf) {
1537        let parent = tempfile::tempdir().expect("parent dir");
1538        // The formatter refuses a directory that already exists.
1539        let log_dir = parent.path().join("node");
1540        let argv = [
1541            "krabka-format",
1542            "--log-dir",
1543            &log_dir.display().to_string(),
1544            "--cluster-id",
1545            "8a2f4e1c-0000-4000-8000-00000000c1d0",
1546            "--node-id",
1547            "1",
1548            "--no-initial-controllers",
1549        ]
1550        .map(ToOwned::to_owned);
1551        check!(krabka_format::run_from_args_with_records(argv, seeded).await == 0);
1552        let formatted = log_dir
1553            .join("__cluster_metadata-0")
1554            .join(FORMAT_TIME_CHECKPOINT);
1555        let target = log_dir.join(subdir);
1556        fs::create_dir_all(&target).expect("create the checkpoint dir");
1557        fs::rename(&formatted, target.join(FORMAT_TIME_CHECKPOINT))
1558            .expect("move the format-time checkpoint");
1559        let rlmm = log_dir.join(RLMM_SNAPSHOT_RELATIVE);
1560        fs::create_dir_all(rlmm.parent().expect("a parent")).expect("create the rlmm dir");
1561        fs::write(rlmm, RLMM_BYTES).expect("write the rlmm snapshot");
1562        (parent, log_dir)
1563    }
1564
1565    #[yare::parameterized(
1566        controller = { "__cluster_metadata-0" },
1567        observer = { "__cluster_metadata-0/observer" },
1568    )]
1569    #[test_macro(tokio::test(flavor = "multi_thread"))]
1570    async fn a_capture_refuses_a_plain_format_time_checkpoint_and_writes_nothing(subdir: &str) {
1571        let (_parent, log_dir) = formatted_node(Vec::new(), subdir).await;
1572        let ArchiveFixture { root, args, .. } = archive_fixture();
1573
1574        let error = capture(Some(&log_dir), None, &args)
1575            .await
1576            .result
1577            .expect_err("a capture of the format-time checkpoint fails");
1578
1579        check!(
1580            matches!(&error, BackupError::StaleMetadata(_)),
1581            "got: {error}"
1582        );
1583        let message = error.to_string();
1584        check!(message.contains(FORMAT_TIME_CHECKPOINT), "got: {message}");
1585        check!(
1586            message.contains("metadata_max_snapshot_interval"),
1587            "got: {message}"
1588        );
1589        check!(
1590            fs::read_dir(root.path()).unwrap().next().is_none(),
1591            "the refused capture wrote no artifact"
1592        );
1593    }
1594
1595    #[tokio::test(flavor = "multi_thread")]
1596    async fn an_undecodable_format_time_checkpoint_is_an_integrity_failure_that_writes_nothing() {
1597        let log_dir = node_with_rlmm();
1598        let metadata = log_dir.path().join("__cluster_metadata-0");
1599        fs::create_dir_all(&metadata).expect("create the checkpoint dir");
1600        fs::write(metadata.join(FORMAT_TIME_CHECKPOINT), CHECKPOINT_BYTES)
1601            .expect("write a damaged format-time checkpoint");
1602        let ArchiveFixture { root, args, .. } = archive_fixture();
1603
1604        let error = capture(Some(log_dir.path()), None, &args)
1605            .await
1606            .result
1607            .expect_err("a damaged checkpoint fails the capture");
1608
1609        check!(matches!(&error, BackupError::Integrity(_)), "got: {error}");
1610        check!(
1611            error.to_string().contains(FORMAT_TIME_CHECKPOINT),
1612            "got: {error}"
1613        );
1614        check!(
1615            fs::read_dir(root.path()).unwrap().next().is_none(),
1616            "the failed capture wrote no artifact"
1617        );
1618    }
1619
1620    #[yare::parameterized(
1621        controller = { "__cluster_metadata-0" },
1622        observer = { "__cluster_metadata-0/observer" },
1623    )]
1624    #[test_macro(tokio::test(flavor = "multi_thread"))]
1625    async fn a_capture_takes_the_format_time_checkpoint_a_restore_seeded(subdir: &str) {
1626        let (_parent, log_dir) = formatted_node(restored_topic(), subdir).await;
1627        let checkpoint = fs::read(log_dir.join(subdir).join(FORMAT_TIME_CHECKPOINT))
1628            .expect("read the seeded checkpoint");
1629        let ArchiveFixture {
1630            root: _root,
1631            args,
1632            store,
1633        } = archive_fixture();
1634
1635        let id = capture(Some(&log_dir), None, &args)
1636            .await
1637            .result
1638            .expect("a capture of a restored node succeeds");
1639
1640        let manifest = capture_manifest(&store, &id).await;
1641        check!(
1642            manifest
1643                .artifact(METADATA_CHECKPOINT)
1644                .map(|a| a.sha256.clone())
1645                == Some(sha256_hex(&checkpoint))
1646        );
1647    }
1648
1649    #[yare::parameterized(
1650        an_hour_old = { 1_700_000_000_000, 1_700_003_600_000, "its newest record is 3600s old" },
1651        no_timestamp = { 0, 1_700_003_600_000, "the age of its newest record is unknown" },
1652    )]
1653    fn the_checkpoint_note_gives_the_age_of_its_newest_record(
1654        last_contained_ms: i64,
1655        now_ms: u64,
1656        age: &str,
1657    ) {
1658        let bytes = krabka_raft::serialize_metadata_snapshot(
1659            &MetadataImage::new(uuid::Uuid::from_u128(7)),
1660            last_contained_ms,
1661        )
1662        .expect("serialize a checkpoint");
1663
1664        check!(
1665            checkpoint_note(&bytes, now_ms)
1666                == format!(
1667                    "cluster-metadata.checkpoint: {age}. Metadata changes committed after it \
1668                     are not in this capture"
1669                )
1670        );
1671    }
1672
1673    #[test]
1674    fn the_checkpoint_note_of_an_undecodable_checkpoint_has_no_age() {
1675        check!(
1676            checkpoint_note(CHECKPOINT_BYTES, 1)
1677                == "cluster-metadata.checkpoint: the age of its newest record is unknown. \
1678                    Metadata changes committed after it are not in this capture"
1679        );
1680    }
1681
1682    #[tokio::test(flavor = "multi_thread")]
1683    async fn a_capture_takes_a_controller_checkpoint_past_the_format_time_one() {
1684        let log_dir = node_with_rlmm();
1685        let metadata = log_dir.path().join("__cluster_metadata-0");
1686        fs::create_dir_all(&metadata).expect("create the checkpoint dir");
1687        fs::write(
1688            metadata.join("00000000000000000000-0000000000.checkpoint"),
1689            b"format-time",
1690        )
1691        .expect("write the format-time checkpoint");
1692        write_checkpoint(log_dir.path());
1693        let ArchiveFixture {
1694            root: _root,
1695            args,
1696            store,
1697        } = archive_fixture();
1698
1699        let id = capture(Some(log_dir.path()), None, &args)
1700            .await
1701            .result
1702            .expect("a capture past the format-time checkpoint succeeds");
1703
1704        let manifest = capture_manifest(&store, &id).await;
1705        check!(
1706            manifest
1707                .artifact(METADATA_CHECKPOINT)
1708                .map(|a| a.sha256.clone())
1709                == Some(sha256_hex(CHECKPOINT_BYTES))
1710        );
1711    }
1712
1713    #[tokio::test(flavor = "multi_thread")]
1714    async fn a_capture_reports_each_input_it_did_not_find_and_takes_the_other() {
1715        let log_dir = tempfile::tempdir().expect("log dir");
1716        write_checkpoint(log_dir.path());
1717        let archive_root = tempfile::tempdir().expect("archive root");
1718        let args = archive_args(archive_root.path());
1719
1720        let id = capture(Some(log_dir.path()), None, &args)
1721            .await
1722            .result
1723            .expect("a capture that found one input succeeds");
1724
1725        let store = Archive::open(&args).expect("open the archive");
1726        let manifest = capture_manifest(&store, &id).await;
1727        check!(manifest.artifact(RLMM_SNAPSHOT) == None);
1728        check!(manifest.artifact(METADATA_CHECKPOINT).is_some());
1729    }
1730
1731    #[tokio::test(flavor = "multi_thread")]
1732    async fn a_capture_that_finds_nothing_names_the_flags_that_would_have_helped() {
1733        let archive_root = tempfile::tempdir().expect("archive root");
1734        let args = archive_args(archive_root.path());
1735
1736        let message = capture(None, None, &args)
1737            .await
1738            .result
1739            .expect_err("a capture with no source fails")
1740            .to_string();
1741        check!(message.contains("pass --log-dir"), "got: {message}");
1742
1743        let empty = tempfile::tempdir().expect("an empty log dir");
1744        let message = capture(Some(empty.path()), None, &args)
1745            .await
1746            .result
1747            .expect_err("a capture that found nothing fails")
1748            .to_string();
1749        check!(message.contains("is absent"), "got: {message}");
1750        check!(message.contains("checkpoint"), "got: {message}");
1751    }
1752
1753    #[tokio::test(flavor = "multi_thread")]
1754    async fn an_unreadable_snapshot_is_reported_rather_than_treated_as_absent() {
1755        let log_dir = tempfile::tempdir().expect("log dir");
1756        // A directory where the snapshot file belongs: the read fails with
1757        // something other than `NotFound`, which is not "the node has none".
1758        fs::create_dir_all(log_dir.path().join("remote-log-metadata/snapshot"))
1759            .expect("create a directory in the snapshot's place");
1760        let archive_root = tempfile::tempdir().expect("archive root");
1761
1762        let error = capture(
1763            Some(log_dir.path()),
1764            None,
1765            &archive_args(archive_root.path()),
1766        )
1767        .await
1768        .result
1769        .expect_err("an unreadable snapshot fails the capture");
1770        assert!(let BackupError::Io(_) = &error, "got: {error}");
1771    }
1772
1773    #[tokio::test(flavor = "multi_thread")]
1774    async fn a_capture_of_group_offsets_reports_the_cluster_it_could_not_reach() {
1775        let archive_root = tempfile::tempdir().expect("archive root");
1776        let address = closed_address();
1777
1778        let error = capture(None, Some(&address), &archive_args(archive_root.path()))
1779            .await
1780            .result
1781            .expect_err("a capture cannot read offsets from a cluster that is not there");
1782        check_unreachable(&error, &address);
1783    }
1784
1785    #[tokio::test(flavor = "multi_thread")]
1786    async fn list_names_every_capture_and_says_so_when_there_are_none() {
1787        let archive_root = tempfile::tempdir().expect("archive root");
1788        let args = archive_args(archive_root.path());
1789        check!(
1790            list(&args)
1791                .await
1792                .result
1793                .expect("list an empty archive")
1794                .is_empty()
1795        );
1796
1797        let node = node_with_both_inputs();
1798        let first = capture(Some(node.path()), None, &args)
1799            .await
1800            .result
1801            .expect("capture the node");
1802        // A second capture id that sorts after the first, written by hand so
1803        // the two do not depend on the clock ticking between them.
1804        let store = Archive::open(&args).expect("open the archive");
1805        let second = "9999999999999999";
1806        write_offsets_capture(&store, second, &offsets_json(), &offsets_json()).await;
1807
1808        check!(
1809            list(&args).await.result.expect("list the captures") == vec![first, second.to_owned()]
1810        );
1811    }
1812
1813    #[tokio::test(flavor = "multi_thread")]
1814    async fn list_reports_a_capture_whose_manifest_cannot_be_read_and_keeps_going() {
1815        let ArchiveFixture {
1816            root: _archive_root,
1817            args,
1818            store,
1819        } = archive_fixture();
1820        store
1821            .put(&capture_key("0000000000000001", MANIFEST), b"{".to_vec())
1822            .await
1823            .expect("write a truncated manifest");
1824
1825        let report = list(&args).await;
1826        check!(
1827            report
1828                .result
1829                .expect("an unreadable manifest is listed, not fatal")
1830                == vec!["0000000000000001".to_owned()]
1831        );
1832        assert!(let [BackupEvent::Listed { id, manifest }] = report.events.as_slice());
1833        check!(id == "0000000000000001");
1834        check!(matches!(manifest, Err(BackupError::Json { .. })));
1835    }
1836
1837    #[tokio::test(flavor = "multi_thread")]
1838    async fn verify_accepts_a_capture_it_just_wrote_under_either_selector() {
1839        let CapturedNode {
1840            node: _node,
1841            archive_root: _archive_root,
1842            args,
1843            id,
1844        } = captured_node().await;
1845
1846        check!(verify("latest", &args).await.result.is_ok());
1847        check!(verify(&id, &args).await.result.is_ok());
1848    }
1849
1850    #[tokio::test(flavor = "multi_thread")]
1851    async fn verify_reports_an_artifact_the_archive_no_longer_holds_whole() {
1852        let CapturedNode {
1853            node: _node,
1854            archive_root,
1855            args,
1856            id,
1857        } = captured_node().await;
1858
1859        // What a half-finished upload leaves behind, and exactly what a
1860        // restore must not be handed.
1861        fs::write(
1862            archive_root.path().join(capture_key(&id, RLMM_SNAPSHOT)),
1863            b"rlmm snapshot byt",
1864        )
1865        .expect("truncate the captured snapshot");
1866
1867        let error = verify("latest", &args)
1868            .await
1869            .result
1870            .expect_err("a truncated artifact fails verification");
1871        assert!(let BackupError::Integrity(_) = &error, "got: {error}");
1872        check!(error.to_string().contains(RLMM_SNAPSHOT), "got: {error}");
1873    }
1874
1875    #[tokio::test(flavor = "multi_thread")]
1876    async fn a_capture_that_is_not_in_the_archive_is_named_rather_than_guessed_at() {
1877        let archive_root = tempfile::tempdir().expect("archive root");
1878        let args = archive_args(archive_root.path());
1879
1880        let error = verify("latest", &args)
1881            .await
1882            .result
1883            .expect_err("an empty archive has no newest capture");
1884        assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1885
1886        let node = node_with_both_inputs();
1887        capture(Some(node.path()), None, &args)
1888            .await
1889            .result
1890            .expect("capture the node");
1891        let error = verify("0000000000000007", &args)
1892            .await
1893            .result
1894            .expect_err("a capture the archive does not hold");
1895        assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1896        check!(
1897            error.to_string().contains("0000000000000007"),
1898            "got: {error}"
1899        );
1900    }
1901
1902    #[tokio::test(flavor = "multi_thread")]
1903    async fn a_dry_run_reports_every_offset_and_commits_nothing() {
1904        let ArchiveFixture {
1905            root: _archive_root,
1906            args,
1907            store,
1908        } = archive_fixture();
1909        let offsets = offsets_json();
1910        write_offsets_capture(&store, "0000000000000001", &offsets, &offsets).await;
1911
1912        // The cluster address is one nothing is listening on: a dry run must
1913        // not reach for it at all.
1914        let report = restore_offsets("latest", &closed_address(), true, &args).await;
1915        check!(report.result.expect("a dry run reads the capture alone") == 1);
1916        assert!(let [BackupEvent::WouldCommit { group, topic, partition, offset }] = report.events.as_slice());
1917        let expected = GroupOffsetsFile::from_slice(&offsets, GROUP_OFFSETS).unwrap();
1918        let expected_group = &expected.groups[0];
1919        let expected_offset = &expected_group.offsets[0];
1920        check!(
1921            (group.as_str(), topic.as_str(), *partition, *offset)
1922                == (
1923                    expected_group.group.as_str(),
1924                    expected_offset.topic.as_str(),
1925                    expected_offset.partition,
1926                    expected_offset.offset
1927                )
1928        );
1929    }
1930
1931    #[tokio::test(flavor = "multi_thread")]
1932    async fn a_capture_without_offsets_cannot_restore_them() {
1933        let node = node_with_both_inputs();
1934        let archive_root = tempfile::tempdir().expect("archive root");
1935        let args = archive_args(archive_root.path());
1936        capture(Some(node.path()), None, &args)
1937            .await
1938            .result
1939            .expect("capture the node");
1940
1941        let error = restore_offsets("latest", &closed_address(), true, &args)
1942            .await
1943            .result
1944            .expect_err("a capture of the two files holds no offsets");
1945        assert!(let BackupError::NoSuchCapture(_) = &error, "got: {error}");
1946        check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
1947    }
1948
1949    #[tokio::test(flavor = "multi_thread")]
1950    async fn offsets_that_do_not_match_their_digest_are_never_committed() {
1951        let ArchiveFixture {
1952            root: _archive_root,
1953            args,
1954            store,
1955        } = archive_fixture();
1956        write_offsets_capture(&store, "0000000000000001", b"{}", &offsets_json()).await;
1957
1958        let error = restore_offsets("latest", &closed_address(), true, &args)
1959            .await
1960            .result
1961            .expect_err("offsets that do not match the manifest are an integrity failure");
1962        assert!(let BackupError::Integrity(_) = &error, "got: {error}");
1963    }
1964
1965    #[tokio::test(flavor = "multi_thread")]
1966    async fn offsets_that_are_not_the_captured_shape_name_the_object_that_holds_them() {
1967        let ArchiveFixture {
1968            root: _archive_root,
1969            args,
1970            store,
1971        } = archive_fixture();
1972        let bytes = b"[]";
1973        write_offsets_capture(&store, "0000000000000001", bytes, bytes).await;
1974
1975        let error = restore_offsets("latest", &closed_address(), true, &args)
1976            .await
1977            .result
1978            .expect_err("a JSON array is not the captured shape");
1979        assert!(let BackupError::Json { .. } = &error, "got: {error}");
1980        check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
1981    }
1982
1983    #[tokio::test(flavor = "multi_thread")]
1984    async fn offsets_or_a_manifest_from_before_1_0_are_refused_as_an_unsupported_version() {
1985        let archive_root = tempfile::tempdir().expect("archive root");
1986        let args = archive_args(archive_root.path());
1987        let store = Archive::open(&args).expect("open the archive");
1988        let offsets = br#"{"groups":[]}"#;
1989        write_offsets_capture(&store, "0000000000000001", offsets, offsets).await;
1990
1991        let error = restore_offsets("latest", &closed_address(), true, &args)
1992            .await
1993            .result
1994            .expect_err("an offsets file with no version is refused");
1995        check!(
1996            let BackupError::UnsupportedVersion { found: None, expected: GROUP_OFFSETS_VERSION, .. } = &error,
1997            "got: {error}"
1998        );
1999        check!(error.to_string().contains(GROUP_OFFSETS), "got: {error}");
2000        check!(
2001            error.to_string().contains("predates krabka 1.0"),
2002            "got: {error}"
2003        );
2004
2005        let mut manifest: serde_json::Value = serde_json::from_slice(
2006            &store
2007                .get(&capture_key("0000000000000001", MANIFEST))
2008                .await
2009                .expect("read the manifest"),
2010        )
2011        .expect("decode the manifest");
2012        manifest["version"] = serde_json::json!(MANIFEST_VERSION + 1);
2013        store
2014            .put(
2015                &capture_key("0000000000000001", MANIFEST),
2016                serde_json::to_vec(&manifest).expect("encode the manifest"),
2017            )
2018            .await
2019            .expect("rewrite the manifest");
2020
2021        let error = restore_offsets("latest", &closed_address(), true, &args)
2022            .await
2023            .result
2024            .expect_err("a manifest of a later version is refused");
2025        assert2::assert!(
2026            let BackupError::UnsupportedVersion {
2027                found: Some(found),
2028                expected: MANIFEST_VERSION,
2029                ..
2030            } = &error,
2031            "got: {error}"
2032        );
2033        check!(found == "1");
2034        check!(error.to_string().contains(MANIFEST), "got: {error}");
2035    }
2036
2037    #[tokio::test(flavor = "multi_thread")]
2038    async fn a_commit_reports_the_cluster_it_could_not_reach() {
2039        let ArchiveFixture {
2040            root: _archive_root,
2041            args,
2042            store,
2043        } = archive_fixture();
2044        let offsets = offsets_json();
2045        write_offsets_capture(&store, "0000000000000001", &offsets, &offsets).await;
2046        let address = closed_address();
2047
2048        // The admin client's bootstrap connection is the first thing to fail.
2049        let error = restore_offsets("latest", &address, false, &args)
2050            .await
2051            .result
2052            .expect_err("a commit cannot reach a cluster that is not there");
2053        check_unreachable(&error, &address);
2054    }
2055
2056    #[tokio::test(flavor = "multi_thread")]
2057    async fn a_capture_that_holds_no_offsets_commits_nothing_and_asks_the_cluster_nothing() {
2058        let ArchiveFixture {
2059            root: _archive_root,
2060            args,
2061            store,
2062        } = archive_fixture();
2063        let empty = serde_json::to_vec(&GroupOffsetsFile::default()).expect("encode the offsets");
2064        write_offsets_capture(&store, "0000000000000001", &empty, &empty).await;
2065
2066        // A capture taken while no group had committed anything names no
2067        // topic, so there is no metadata to look up and no commit to send:
2068        // the restore succeeds having asked the cluster for nothing, which is
2069        // why the address below is one nothing is listening on.
2070        check!(
2071            restore_offsets("latest", &closed_address(), false, &args)
2072                .await
2073                .result
2074                .expect("an empty capture restores nothing")
2075                == 0
2076        );
2077    }
2078}