1use std::{
2 env, fmt,
3 fs::{create_dir, read_dir, remove_dir_all, rename},
4 path::{Path, PathBuf},
5 process::Command,
6};
7
8use anyhow::{Context, anyhow};
9use clap::{Parser, ValueEnum};
10
11use mithril_client::{
12 MithrilError, MithrilResult,
13 common::{CardanoNetwork, MagicId},
14};
15
16use crate::utils::{
17 ArchiveUnpacker, GitHubReleaseRetriever, HttpDownloader, ReqwestGitHubApiClient,
18 ReqwestHttpDownloader, copy_dir, remove_dir_contents,
19};
20
21const GITHUB_ORGANIZATION: &str = "IntersectMBO";
22const GITHUB_REPOSITORY: &str = "cardano-node";
23
24const LATEST_DISTRIBUTION_TAG: &str = "latest";
25const PRERELEASE_DISTRIBUTION_TAG: &str = "pre-release";
26
27const WORK_DIR: &str = "tmp";
28const CARDANO_DISTRIBUTION_DIR: &str = "cardano-node-distribution";
29const SNAPSHOTS_DIR: &str = "snapshots";
30
31const SNAPSHOT_CONVERTER_BIN_DIR: &str = "bin";
32const SNAPSHOT_CONVERTER_BIN_NAME_UNIX: &str = "snapshot-converter";
33const SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS: &str = "snapshot-converter.exe";
34const SNAPSHOT_CONVERTER_CONFIG_DIR: &str = "share";
35const SNAPSHOT_CONVERTER_CONFIG_FILE: &str = "config.json";
36
37const LEDGER_DIR: &str = "ledger";
38const PROTOCOL_MAGIC_ID_FILE: &str = "protocolMagicId";
39
40const CONVERSION_FALLBACK_LIMIT: usize = 2;
41
42#[derive(Debug, Clone, ValueEnum, Eq, PartialEq)]
43enum UTxOHDFlavor {
44 #[clap(name = "Legacy")]
45 Legacy,
46 #[clap(name = "LMDB")]
47 Lmdb,
48}
49
50impl fmt::Display for UTxOHDFlavor {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::Legacy => write!(f, "Legacy"),
54 Self::Lmdb => write!(f, "LMDB"),
55 }
56 }
57}
58
59#[derive(Debug, Clone, ValueEnum, Eq, PartialEq)]
60enum CardanoNetworkCliArg {
61 Preview,
62 Preprod,
63 Mainnet,
64}
65
66impl fmt::Display for CardanoNetworkCliArg {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::Preview => write!(f, "preview"),
70 Self::Preprod => write!(f, "preprod"),
71 Self::Mainnet => write!(f, "mainnet"),
72 }
73 }
74}
75
76impl TryFrom<CardanoNetwork> for CardanoNetworkCliArg {
77 type Error = MithrilError;
78
79 fn try_from(network: CardanoNetwork) -> Result<Self, Self::Error> {
80 match network {
81 CardanoNetwork::MainNet => Ok(Self::Mainnet),
82 CardanoNetwork::TestNet(magic_id) => match magic_id {
83 CardanoNetwork::PREVIEW_MAGIC_ID => Ok(Self::Preview),
84 CardanoNetwork::PREPROD_MAGIC_ID => Ok(Self::Preprod),
85 _ => Err(anyhow!(
86 "Cardano network not supported for ledger state snapshot conversion: {network:?}",
87 )),
88 },
89 }
90 }
91}
92
93#[cfg_attr(test, mockall::automock)]
94trait SnapshotConverter {
95 fn convert(&self, input_path: &Path, output_path: &Path) -> MithrilResult<()>;
96}
97
98struct SnapshotConverterBin {
99 pub converter_bin: PathBuf,
100 pub config_path: PathBuf,
101 pub utxo_hd_flavor: UTxOHDFlavor,
102}
103
104impl SnapshotConverter for SnapshotConverterBin {
105 fn convert(&self, input_path: &Path, output_path: &Path) -> MithrilResult<()> {
106 let status = Command::new(self.converter_bin.clone())
107 .arg("Mem")
108 .arg(input_path)
109 .arg(self.utxo_hd_flavor.to_string())
110 .arg(output_path)
111 .arg("cardano")
112 .arg("--config")
113 .arg(self.config_path.clone())
114 .status()
115 .with_context(|| {
116 format!(
117 "Failed to execute snapshot-converter binary at {}",
118 self.converter_bin.display()
119 )
120 })?;
121
122 if !status.success() {
123 return Err(anyhow!(
124 "Failure while running snapshot-converter binary, exited with status code: {:?}",
125 status.code().map_or(String::from("unknown"), |c| c.to_string())
126 ));
127 }
128
129 Ok(())
130 }
131}
132
133#[derive(Parser, Debug, Clone)]
135pub struct SnapshotConverterCommand {
136 #[clap(long)]
138 db_directory: PathBuf,
139
140 #[clap(long)]
144 cardano_node_version: String,
145
146 #[clap(long)]
148 #[deprecated(
149 since = "0.12.12",
150 note = "optional: automatically detected from the protocolMagicId file"
151 )]
152 cardano_network: Option<CardanoNetworkCliArg>,
153
154 #[clap(long)]
156 utxo_hd_flavor: UTxOHDFlavor,
157
158 #[clap(long)]
160 commit: bool,
161
162 #[clap(long, env = "GITHUB_TOKEN")]
164 github_token: Option<String>,
165}
166
167impl SnapshotConverterCommand {
168 pub async fn execute(&self) -> MithrilResult<()> {
170 let work_dir = self.db_directory.join(WORK_DIR);
171 create_dir(&work_dir).with_context(|| {
172 format!(
173 "Failed to create snapshot converter work directory: {}",
174 work_dir.display()
175 )
176 })?;
177 let distribution_dir = work_dir.join(CARDANO_DISTRIBUTION_DIR);
178
179 let result = {
180 create_dir(&distribution_dir).with_context(|| {
181 format!(
182 "Failed to create distribution directory: {}",
183 distribution_dir.display()
184 )
185 })?;
186 let archive_path = Self::download_cardano_node_distribution(
187 ReqwestGitHubApiClient::new(self.github_token.clone())?,
188 ReqwestHttpDownloader::new()?,
189 &self.cardano_node_version,
190 &distribution_dir,
191 )
192 .await
193 .with_context(|| "Failed to download Cardano node distribution")?;
194
195 println!(
196 "Unpacking distribution from archive: {}",
197 archive_path.display()
198 );
199 ArchiveUnpacker::default()
200 .unpack(&archive_path, &distribution_dir)
201 .with_context(|| {
202 format!(
203 "Failed to unpack distribution to directory: {}",
204 distribution_dir.display()
205 )
206 })?;
207 println!(
208 "Distribution unpacked successfully to: {}",
209 distribution_dir.display()
210 );
211
212 #[allow(deprecated)]
213 let cardano_network = if let Some(network) = &self.cardano_network {
214 network.clone()
215 } else {
216 Self::detect_cardano_network(&self.db_directory).with_context(|| {
217 format!(
218 "Could not detect Cardano network from the database directory: {}",
219 self.db_directory.display()
220 )
221 })?
222 };
223 Self::convert_ledger_state_snapshot(
224 &work_dir,
225 &self.db_directory,
226 &distribution_dir,
227 &cardano_network,
228 &self.utxo_hd_flavor,
229 self.commit,
230 )
231 .with_context(|| {
232 format!(
233 "Failed to convert ledger snapshot to flavor: {}",
234 self.utxo_hd_flavor
235 )
236 })?;
237
238 Ok(())
239 };
240
241 if let Err(e) = Self::cleanup(&work_dir, &distribution_dir, self.commit, result.is_ok()) {
242 eprintln!(
243 "Failed to clean up temporary directory {} after execution: {}",
244 distribution_dir.display(),
245 e
246 );
247 }
248
249 result
250 }
251
252 async fn download_cardano_node_distribution(
253 github_api_client: impl GitHubReleaseRetriever,
254 http_downloader: impl HttpDownloader,
255 tag: &str,
256 target_dir: &Path,
257 ) -> MithrilResult<PathBuf> {
258 println!("Downloading Cardano node distribution for tag: '{tag}'...");
259 let release = match tag {
260 LATEST_DISTRIBUTION_TAG => github_api_client
261 .get_latest_release(GITHUB_ORGANIZATION, GITHUB_REPOSITORY)
262 .await
263 .with_context(|| "Failed to get latest release")?,
264 PRERELEASE_DISTRIBUTION_TAG => github_api_client
265 .get_prerelease(GITHUB_ORGANIZATION, GITHUB_REPOSITORY)
266 .await
267 .with_context(|| "Failed to get pre-release")?,
268 _ => github_api_client
269 .get_release_by_tag(GITHUB_ORGANIZATION, GITHUB_REPOSITORY, tag)
270 .await
271 .with_context(|| format!("Failed to get release by tag: {tag}"))?,
272 };
273 let asset = release
274 .get_asset_for_os(env::consts::OS)?
275 .ok_or_else(|| anyhow!("No asset found for platform: {}", env::consts::OS))
276 .with_context(|| {
277 format!(
278 "Failed to find asset for current platform: {}",
279 env::consts::OS
280 )
281 })?;
282 let archive_path = http_downloader
283 .download_file(asset.browser_download_url.parse()?, target_dir, &asset.name)
284 .await?;
285
286 println!(
287 "Distribution downloaded successfully. Archive location: {}",
288 archive_path.display()
289 );
290
291 Ok(archive_path)
292 }
293
294 fn convert_ledger_state_snapshot(
295 work_dir: &Path,
296 db_dir: &Path,
297 distribution_dir: &Path,
298 cardano_network: &CardanoNetworkCliArg,
299 utxo_hd_flavor: &UTxOHDFlavor,
300 commit: bool,
301 ) -> MithrilResult<()> {
302 println!("Converting ledger state snapshot to '{utxo_hd_flavor}' flavor");
303 let converter_bin =
304 Self::get_snapshot_converter_binary_path(distribution_dir, env::consts::OS)?;
305 let config_path =
306 Self::get_snapshot_converter_config_path(distribution_dir, cardano_network);
307 let snapshots = Self::find_most_recent_snapshots(db_dir, CONVERSION_FALLBACK_LIMIT)?;
308 let converter_bin = SnapshotConverterBin {
309 converter_bin,
310 config_path,
311 utxo_hd_flavor: utxo_hd_flavor.clone(),
312 };
313 let converted_snapshot_path = Self::try_convert(
314 work_dir,
315 utxo_hd_flavor,
316 &snapshots,
317 Box::new(converter_bin),
318 )?;
319
320 if commit {
321 Self::commit_converted_snapshot(db_dir, &converted_snapshot_path).with_context(
322 || "Failed to overwrite the ledger state with the converted snapshot.",
323 )?;
324 } else {
325 println!("Snapshot location: {}", converted_snapshot_path.display());
326 }
327
328 Ok(())
329 }
330
331 fn try_convert(
332 work_dir: &Path,
333 utxo_hd_flavor: &UTxOHDFlavor,
334 snapshots: &[PathBuf],
335 converter: Box<dyn SnapshotConverter>,
336 ) -> MithrilResult<PathBuf> {
337 let snapshots_dir = work_dir.join(SNAPSHOTS_DIR);
338
339 for (i, snapshot) in snapshots.iter().enumerate() {
340 let attempt = i + 1;
341 println!("Converting '{}' (attempt #{})", snapshot.display(), attempt);
342
343 let input_path = copy_dir(snapshot, &snapshots_dir)?;
344 let output_path = Self::compute_converted_snapshot_output_path(
345 &snapshots_dir,
346 &input_path,
347 utxo_hd_flavor,
348 )?;
349
350 match converter.convert(&input_path, &output_path) {
351 Ok(()) => {
352 return {
353 println!(
354 "Successfully converted ledger state snapshot: '{}'",
355 snapshot.display()
356 );
357
358 Ok(output_path)
359 };
360 }
361 Err(e) => {
362 eprintln!(
363 "Failed to convert ledger state snapshot '{}': {e}",
364 snapshot.display()
365 );
366 continue;
367 }
368 };
369 }
370
371 Err(anyhow!(
372 "Failed to convert any of the provided ledger state snapshots to the desired flavor: {}",
373 utxo_hd_flavor
374 ))
375 }
376
377 fn get_snapshot_converter_binary_path(
378 distribution_dir: &Path,
379 target_os: &str,
380 ) -> MithrilResult<PathBuf> {
381 let base_path = distribution_dir.join(SNAPSHOT_CONVERTER_BIN_DIR);
382 let binary_name = match target_os {
383 "linux" | "macos" => SNAPSHOT_CONVERTER_BIN_NAME_UNIX,
384 "windows" => SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS,
385 _ => return Err(anyhow!("Unsupported platform: {}", target_os)),
386 };
387
388 Ok(base_path.join(binary_name))
389 }
390
391 fn get_snapshot_converter_config_path(
392 distribution_dir: &Path,
393 network: &CardanoNetworkCliArg,
394 ) -> PathBuf {
395 distribution_dir
396 .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
397 .join(network.to_string())
398 .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
399 }
400
401 fn get_sorted_snapshot_dirs(ledger_dir: &Path) -> MithrilResult<Vec<(u64, PathBuf)>> {
405 let entries = read_dir(ledger_dir).with_context(|| {
406 format!(
407 "Failed to read ledger state snapshots directory: {}",
408 ledger_dir.display()
409 )
410 })?;
411
412 let mut snapshots = entries
413 .filter_map(|entry| {
414 let path = entry.ok()?.path();
415 if !path.is_dir() {
416 return None;
417 }
418 SnapshotConverterCommand::extract_slot_number(&path)
419 .ok()
420 .map(|slot| (slot, path))
421 })
422 .collect::<Vec<_>>();
423
424 snapshots.sort_by_key(|(slot, _)| *slot);
425
426 Ok(snapshots)
427 }
428
429 fn find_most_recent_snapshots(db_dir: &Path, count: usize) -> MithrilResult<Vec<PathBuf>> {
430 let ledger_dir = db_dir.join(LEDGER_DIR);
431 let snapshots = Self::get_sorted_snapshot_dirs(&ledger_dir)?
432 .into_iter()
433 .rev()
434 .take(count)
435 .map(|(_, path)| path)
436 .collect::<Vec<_>>();
437
438 if snapshots.is_empty() {
439 return Err(anyhow!(
440 "No valid ledger state snapshots found in directory: {}",
441 ledger_dir.display()
442 ));
443 }
444
445 Ok(snapshots)
446 }
447
448 fn compute_converted_snapshot_output_path(
449 snapshots_dir: &Path,
450 input_snapshot: &Path,
451 flavor: &UTxOHDFlavor,
452 ) -> MithrilResult<PathBuf> {
453 let slot_number = Self::extract_slot_number(input_snapshot).with_context(|| {
454 format!(
455 "Failed to extract slot number from: {}",
456 input_snapshot.display()
457 )
458 })?;
459 let converted_snapshot_path = snapshots_dir.join(format!(
460 "{}_{}",
461 slot_number,
462 flavor.to_string().to_lowercase()
463 ));
464
465 Ok(converted_snapshot_path)
466 }
467
468 fn extract_slot_number(path: &Path) -> MithrilResult<u64> {
469 let file_name = path
470 .file_name()
471 .ok_or_else(|| anyhow!("No filename in path: {}", path.display()))?;
472 let file_name_str = file_name
473 .to_str()
474 .ok_or_else(|| anyhow!("Invalid UTF-8 in path filename: {:?}", file_name))?;
475
476 file_name_str
477 .parse::<u64>()
478 .with_context(|| format!("Invalid slot number in path filename: {file_name_str}"))
479 }
480
481 fn commit_converted_snapshot(
483 db_dir: &Path,
484 converted_snapshot_path: &Path,
485 ) -> MithrilResult<()> {
486 let ledger_dir = db_dir.join(LEDGER_DIR);
487 println!(
488 "Upgrading and replacing ledger state in {} with converted snapshot: {}",
489 ledger_dir.display(),
490 converted_snapshot_path.display()
491 );
492 let filename = converted_snapshot_path
493 .file_name()
494 .ok_or_else(|| anyhow!("Missing filename in converted snapshot path"))?
495 .to_string_lossy();
496 let (slot_number, _) = filename
497 .split_once('_')
498 .ok_or_else(|| anyhow!("Invalid converted snapshot name format: {}", filename))?;
499 remove_dir_contents(&ledger_dir).with_context(|| {
500 format!(
501 "Failed to remove contents of ledger directory: {}",
502 ledger_dir.display()
503 )
504 })?;
505 let destination = ledger_dir.join(slot_number);
506 rename(converted_snapshot_path, &destination).with_context(|| {
507 format!(
508 "Failed to move converted snapshot to ledger directory: {}",
509 destination.display()
510 )
511 })?;
512
513 Ok(())
514 }
515
516 fn cleanup(
517 work_dir: &Path,
518 distribution_dir: &Path,
519 commit: bool,
520 success: bool,
521 ) -> MithrilResult<()> {
522 match (success, commit) {
523 (true, true) => {
524 remove_dir_all(distribution_dir)?;
525 remove_dir_all(work_dir)?;
526 }
527 (true, false) => {
528 remove_dir_all(distribution_dir)?;
529 }
530 (false, _) => {
531 remove_dir_all(distribution_dir)?;
532 remove_dir_all(work_dir)?;
533 }
534 }
535
536 Ok(())
537 }
538
539 fn detect_cardano_network(db_dir: &Path) -> MithrilResult<CardanoNetworkCliArg> {
540 let magic_id_path = db_dir.join(PROTOCOL_MAGIC_ID_FILE);
541 let content = std::fs::read_to_string(&magic_id_path).with_context(|| {
542 format!(
543 "Failed to read protocolMagicId file: {}",
544 magic_id_path.display()
545 )
546 })?;
547 let id: MagicId = content
548 .trim()
549 .parse()
550 .with_context(|| format!("Invalid protocolMagicId value: '{}'", content.trim()))?;
551 let network = CardanoNetwork::from(id);
552
553 CardanoNetworkCliArg::try_from(network)
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use std::fs::File;
560
561 use mithril_common::temp_dir_create;
562
563 use super::*;
564
565 mod download_cardano_node_distribution {
566 use mockall::predicate::eq;
567 use reqwest::Url;
568
569 use crate::utils::{GitHubRelease, MockGitHubReleaseRetriever, MockHttpDownloader};
570
571 use super::*;
572
573 #[tokio::test]
574 async fn downloads_latest_release_distribution() {
575 let temp_dir = temp_dir_create!();
576 let release = GitHubRelease::dummy_with_all_supported_assets();
577 let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
578
579 let cloned_release = release.clone();
580 let mut github_api_client = MockGitHubReleaseRetriever::new();
581 github_api_client
582 .expect_get_latest_release()
583 .with(eq(GITHUB_ORGANIZATION), eq(GITHUB_REPOSITORY))
584 .returning(move |_, _| Ok(cloned_release.clone()));
585
586 let mut http_downloader = MockHttpDownloader::new();
587 http_downloader
588 .expect_download_file()
589 .with(
590 eq(Url::parse(&asset.browser_download_url).unwrap()),
591 eq(temp_dir.clone()),
592 eq(asset.name.clone()),
593 )
594 .returning(|_, _, _| Ok(PathBuf::new()));
595
596 SnapshotConverterCommand::download_cardano_node_distribution(
597 github_api_client,
598 http_downloader,
599 LATEST_DISTRIBUTION_TAG,
600 &temp_dir,
601 )
602 .await
603 .unwrap();
604 }
605
606 #[tokio::test]
607 async fn downloads_prerelease_distribution() {
608 let temp_dir = temp_dir_create!();
609 let release = GitHubRelease::dummy_with_all_supported_assets();
610 let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
611
612 let cloned_release = release.clone();
613 let mut github_api_client = MockGitHubReleaseRetriever::new();
614 github_api_client
615 .expect_get_prerelease()
616 .with(eq(GITHUB_ORGANIZATION), eq(GITHUB_REPOSITORY))
617 .returning(move |_, _| Ok(cloned_release.clone()));
618
619 let mut http_downloader = MockHttpDownloader::new();
620 http_downloader
621 .expect_download_file()
622 .with(
623 eq(Url::parse(&asset.browser_download_url).unwrap()),
624 eq(temp_dir.clone()),
625 eq(asset.name.clone()),
626 )
627 .returning(|_, _, _| Ok(PathBuf::new()));
628
629 SnapshotConverterCommand::download_cardano_node_distribution(
630 github_api_client,
631 http_downloader,
632 PRERELEASE_DISTRIBUTION_TAG,
633 &temp_dir,
634 )
635 .await
636 .unwrap();
637 }
638
639 #[tokio::test]
640 async fn downloads_tagged_release_distribution() {
641 let cardano_node_version = "10.3.1";
642 let temp_dir = temp_dir_create!();
643 let release = GitHubRelease::dummy_with_all_supported_assets();
644 let asset = release.get_asset_for_os(env::consts::OS).unwrap().unwrap();
645
646 let cloned_release = release.clone();
647 let mut github_api_client = MockGitHubReleaseRetriever::new();
648 github_api_client
649 .expect_get_release_by_tag()
650 .with(
651 eq(GITHUB_ORGANIZATION),
652 eq(GITHUB_REPOSITORY),
653 eq(cardano_node_version),
654 )
655 .returning(move |_, _, _| Ok(cloned_release.clone()));
656
657 let mut http_downloader = MockHttpDownloader::new();
658 http_downloader
659 .expect_download_file()
660 .with(
661 eq(Url::parse(&asset.browser_download_url).unwrap()),
662 eq(temp_dir.clone()),
663 eq(asset.name.clone()),
664 )
665 .returning(|_, _, _| Ok(PathBuf::new()));
666
667 SnapshotConverterCommand::download_cardano_node_distribution(
668 github_api_client,
669 http_downloader,
670 cardano_node_version,
671 &temp_dir,
672 )
673 .await
674 .unwrap();
675 }
676 }
677
678 mod get_snapshot_converter_binary_path {
679 use super::*;
680
681 #[test]
682 fn returns_correct_binary_path_for_linux() {
683 let distribution_dir = PathBuf::from("/path/to/distribution");
684
685 let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
686 &distribution_dir,
687 "linux",
688 )
689 .unwrap();
690
691 assert_eq!(
692 binary_path,
693 distribution_dir
694 .join(SNAPSHOT_CONVERTER_BIN_DIR)
695 .join(SNAPSHOT_CONVERTER_BIN_NAME_UNIX)
696 );
697 }
698
699 #[test]
700 fn returns_correct_binary_path_for_macos() {
701 let distribution_dir = PathBuf::from("/path/to/distribution");
702
703 let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
704 &distribution_dir,
705 "macos",
706 )
707 .unwrap();
708
709 assert_eq!(
710 binary_path,
711 distribution_dir
712 .join(SNAPSHOT_CONVERTER_BIN_DIR)
713 .join(SNAPSHOT_CONVERTER_BIN_NAME_UNIX)
714 );
715 }
716
717 #[test]
718 fn returns_correct_binary_path_for_windows() {
719 let distribution_dir = PathBuf::from("/path/to/distribution");
720
721 let binary_path = SnapshotConverterCommand::get_snapshot_converter_binary_path(
722 &distribution_dir,
723 "windows",
724 )
725 .unwrap();
726
727 assert_eq!(
728 binary_path,
729 distribution_dir
730 .join(SNAPSHOT_CONVERTER_BIN_DIR)
731 .join(SNAPSHOT_CONVERTER_BIN_NAME_WINDOWS)
732 );
733 }
734 }
735
736 mod get_snapshot_converter_config_path {
737 use super::*;
738
739 #[test]
740 fn returns_config_path_for_mainnet() {
741 let distribution_dir = PathBuf::from("/path/to/distribution");
742 let network = CardanoNetworkCliArg::Mainnet;
743
744 let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
745 &distribution_dir,
746 &network,
747 );
748
749 assert_eq!(
750 config_path,
751 distribution_dir
752 .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
753 .join(network.to_string())
754 .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
755 );
756 }
757
758 #[test]
759 fn returns_config_path_for_preprod() {
760 let distribution_dir = PathBuf::from("/path/to/distribution");
761 let network = CardanoNetworkCliArg::Preprod;
762
763 let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
764 &distribution_dir,
765 &network,
766 );
767
768 assert_eq!(
769 config_path,
770 distribution_dir
771 .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
772 .join(network.to_string())
773 .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
774 );
775 }
776
777 #[test]
778 fn returns_config_path_for_preview() {
779 let distribution_dir = PathBuf::from("/path/to/distribution");
780 let network = CardanoNetworkCliArg::Preview;
781
782 let config_path = SnapshotConverterCommand::get_snapshot_converter_config_path(
783 &distribution_dir,
784 &network,
785 );
786
787 assert_eq!(
788 config_path,
789 distribution_dir
790 .join(SNAPSHOT_CONVERTER_CONFIG_DIR)
791 .join(network.to_string())
792 .join(SNAPSHOT_CONVERTER_CONFIG_FILE)
793 );
794 }
795 }
796
797 mod extract_slot_number {
798 use super::*;
799
800 #[test]
801 fn parses_valid_numeric_path() {
802 let path = PathBuf::from("/whatever").join("123456");
803
804 let slot = SnapshotConverterCommand::extract_slot_number(&path).unwrap();
805
806 assert_eq!(slot, 123456);
807 }
808
809 #[test]
810 fn fails_with_non_numeric_filename() {
811 let path = PathBuf::from("/whatever").join("notanumber");
812
813 SnapshotConverterCommand::extract_slot_number(&path)
814 .expect_err("Should fail with non-numeric filename");
815 }
816
817 #[test]
818 fn fails_if_no_filename() {
819 let path = PathBuf::from("/");
820
821 SnapshotConverterCommand::extract_slot_number(&path)
822 .expect_err("Should fail if path has no filename");
823 }
824 }
825
826 mod compute_converted_snapshot_output_path {
827 use super::*;
828
829 #[test]
830 fn compute_output_path_from_numeric_file_name() {
831 let snapshots_dir = PathBuf::from("/snapshots");
832 let input_snapshot = PathBuf::from("/whatever").join("123456");
833
834 {
835 let snapshot_path =
836 SnapshotConverterCommand::compute_converted_snapshot_output_path(
837 &snapshots_dir,
838 &input_snapshot,
839 &UTxOHDFlavor::Lmdb,
840 )
841 .unwrap();
842
843 assert_eq!(snapshot_path, snapshots_dir.join("123456_lmdb"));
844 }
845
846 {
847 let snapshot_path =
848 SnapshotConverterCommand::compute_converted_snapshot_output_path(
849 &snapshots_dir,
850 &input_snapshot,
851 &UTxOHDFlavor::Legacy,
852 )
853 .unwrap();
854
855 assert_eq!(snapshot_path, snapshots_dir.join("123456_legacy"));
856 }
857 }
858
859 #[test]
860 fn fails_with_invalid_slot_number() {
861 let snapshots_dir = PathBuf::from("/snapshots");
862 let input_snapshot = PathBuf::from("/whatever/notanumber");
863
864 SnapshotConverterCommand::compute_converted_snapshot_output_path(
865 &snapshots_dir,
866 &input_snapshot,
867 &UTxOHDFlavor::Lmdb,
868 )
869 .expect_err("Should fail with invalid slot number");
870 }
871 }
872
873 mod commit_converted_snapshot {
874 use super::*;
875
876 #[test]
877 fn moves_converted_snapshot_to_ledger_directory() {
878 let tmp_dir = temp_dir_create!();
879 let ledger_dir = tmp_dir.join(LEDGER_DIR);
880 create_dir(&ledger_dir).unwrap();
881 let previous_snapshot = ledger_dir.join("123");
882 File::create(&previous_snapshot).unwrap();
883
884 let converted_snapshot = tmp_dir.join("456_lmdb");
885 File::create(&converted_snapshot).unwrap();
886
887 assert!(previous_snapshot.exists());
888 SnapshotConverterCommand::commit_converted_snapshot(&tmp_dir, &converted_snapshot)
889 .unwrap();
890
891 assert!(!previous_snapshot.exists());
892 assert!(ledger_dir.join("456").exists());
893 }
894
895 #[test]
896 fn fails_if_converted_snapshot_has_invalid_filename() {
897 let tmp_dir = temp_dir_create!();
898 let ledger_dir = tmp_dir.join(LEDGER_DIR);
899 create_dir(&ledger_dir).unwrap();
900 let previous_snapshot = ledger_dir.join("123");
901 File::create(&previous_snapshot).unwrap();
902
903 let converted_snapshot = tmp_dir.join("456");
904 File::create(&converted_snapshot).unwrap();
905
906 SnapshotConverterCommand::commit_converted_snapshot(&tmp_dir, &converted_snapshot)
907 .expect_err("Should fail if converted snapshot has invalid filename");
908
909 assert!(previous_snapshot.exists());
910 }
911 }
912
913 mod cleanup {
914 use super::*;
915
916 #[test]
917 fn removes_both_dirs_on_success_when_commit_is_true() {
918 let tmp = temp_dir_create!();
919 let work_dir = tmp.join("workdir_dir");
920 let distribution_dir = tmp.join("distribution_dir");
921 create_dir(&work_dir).unwrap();
922 create_dir(&distribution_dir).unwrap();
923
924 SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, true, true).unwrap();
925
926 assert!(!distribution_dir.exists());
927 assert!(!work_dir.exists());
928 }
929
930 #[test]
931 fn removes_only_distribution_on_success_when_commit_is_false() {
932 let tmp = temp_dir_create!();
933 let work_dir = tmp.join("workdir_dir");
934 let distribution_dir = tmp.join("distribution_dir");
935 create_dir(&work_dir).unwrap();
936 create_dir(&distribution_dir).unwrap();
937
938 SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, true).unwrap();
939
940 assert!(!distribution_dir.exists());
941 assert!(work_dir.exists());
942 }
943
944 #[test]
945 fn removes_both_dirs_on_success_when_commit_is_true_and_distribution_is_nested() {
946 let tmp = temp_dir_create!();
947 let work_dir = tmp.join("workdir_dir");
948 let distribution_dir = work_dir.join("distribution_dir");
949 create_dir(&work_dir).unwrap();
950 create_dir(&distribution_dir).unwrap();
951
952 SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, true, true).unwrap();
953
954 assert!(!distribution_dir.exists());
955 assert!(!work_dir.exists());
956 }
957
958 #[test]
959 fn removes_only_distribution_on_success_when_commit_is_false_and_distribution_is_nested() {
960 let tmp = temp_dir_create!();
961 let work_dir = tmp.join("workdir_dir");
962 let distribution_dir = work_dir.join("distribution_dir");
963 create_dir(&work_dir).unwrap();
964 create_dir(&distribution_dir).unwrap();
965
966 SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, true).unwrap();
967
968 assert!(!distribution_dir.exists());
969 assert!(work_dir.exists());
970 }
971
972 #[test]
973 fn removes_both_dirs_on_failure() {
974 let tmp = temp_dir_create!();
975 let work_dir = tmp.join("workdir_dir");
976 let distribution_dir = tmp.join("distribution_dir");
977 create_dir(&work_dir).unwrap();
978 create_dir(&distribution_dir).unwrap();
979
980 SnapshotConverterCommand::cleanup(&work_dir, &distribution_dir, false, false).unwrap();
981
982 assert!(!distribution_dir.exists());
983 assert!(!work_dir.exists());
984 }
985 }
986
987 mod detect_cardano_network {
988 use super::*;
989
990 fn create_protocol_magic_id_file(db_dir: &Path, magic_id: MagicId) -> PathBuf {
991 let file_path = db_dir.join(PROTOCOL_MAGIC_ID_FILE);
992 std::fs::write(&file_path, magic_id.to_string()).unwrap();
993
994 file_path
995 }
996
997 #[test]
998 fn detects_mainnet() {
999 let db_dir = temp_dir_create!();
1000 create_protocol_magic_id_file(&db_dir, CardanoNetwork::MAINNET_MAGIC_ID);
1001
1002 let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1003
1004 assert_eq!(network, CardanoNetworkCliArg::Mainnet);
1005 }
1006
1007 #[test]
1008 fn detects_preprod() {
1009 let db_dir = temp_dir_create!();
1010 create_protocol_magic_id_file(&db_dir, CardanoNetwork::PREPROD_MAGIC_ID);
1011
1012 let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1013
1014 assert_eq!(network, CardanoNetworkCliArg::Preprod);
1015 }
1016
1017 #[test]
1018 fn detects_preview() {
1019 let db_dir = temp_dir_create!();
1020 create_protocol_magic_id_file(&db_dir, CardanoNetwork::PREVIEW_MAGIC_ID);
1021
1022 let network = SnapshotConverterCommand::detect_cardano_network(&db_dir).unwrap();
1023
1024 assert_eq!(network, CardanoNetworkCliArg::Preview);
1025 }
1026
1027 #[test]
1028 fn fails_on_invalid_network() {
1029 let db_dir = temp_dir_create!();
1030 let invalid_magic_id = 999999;
1031 create_protocol_magic_id_file(&db_dir, invalid_magic_id);
1032
1033 SnapshotConverterCommand::detect_cardano_network(&db_dir)
1034 .expect_err("Should fail with invalid network magic ID");
1035 }
1036
1037 #[test]
1038 fn fails_when_protocol_magic_id_file_is_empty() {
1039 let db_dir = temp_dir_create!();
1040 File::create(db_dir.join(PROTOCOL_MAGIC_ID_FILE)).unwrap();
1041
1042 SnapshotConverterCommand::detect_cardano_network(&db_dir)
1043 .expect_err("Should fail when protocol magic ID file is empty");
1044 }
1045
1046 #[test]
1047 fn fails_when_protocol_magic_id_file_is_missing() {
1048 let db_dir = temp_dir_create!();
1049
1050 assert!(!db_dir.join(PROTOCOL_MAGIC_ID_FILE).exists());
1051
1052 SnapshotConverterCommand::detect_cardano_network(&db_dir)
1053 .expect_err("Should fail when protocol magic ID file is missing");
1054 }
1055 }
1056
1057 mod snapshots_indexing {
1058 use mithril_common::temp_dir_create;
1059
1060 use super::*;
1061
1062 #[test]
1063 fn returns_the_two_most_recent_snapshots() {
1064 let db_dir = temp_dir_create!();
1065 let ledger_dir = db_dir.join(LEDGER_DIR);
1066 create_dir(&ledger_dir).unwrap();
1067
1068 create_dir(ledger_dir.join("1500")).unwrap();
1069 create_dir(ledger_dir.join("500")).unwrap();
1070 create_dir(ledger_dir.join("1000")).unwrap();
1071
1072 let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 2).unwrap();
1073
1074 assert_eq!(
1075 found,
1076 vec![ledger_dir.join("1500"), ledger_dir.join("1000")]
1077 );
1078 }
1079
1080 #[test]
1081 fn returns_list_with_one_entry_if_only_one_valid_snapshot() {
1082 let db_dir = temp_dir_create!();
1083 let ledger_dir = db_dir.join(LEDGER_DIR);
1084 create_dir(&ledger_dir).unwrap();
1085
1086 create_dir(ledger_dir.join("500")).unwrap();
1087
1088 let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 2).unwrap();
1089
1090 assert_eq!(found, vec![ledger_dir.join("500")]);
1091 }
1092
1093 #[test]
1094 fn ignores_non_numeric_and_non_directory_entries() {
1095 let temp_dir = temp_dir_create!();
1096 let ledger_dir = temp_dir.join(LEDGER_DIR);
1097 create_dir(&ledger_dir).unwrap();
1098
1099 create_dir(ledger_dir.join("1000")).unwrap();
1100 File::create(ledger_dir.join("500")).unwrap();
1101 create_dir(ledger_dir.join("invalid")).unwrap();
1102
1103 let found = SnapshotConverterCommand::find_most_recent_snapshots(&temp_dir, 2).unwrap();
1104
1105 assert_eq!(found, vec![ledger_dir.join("1000")]);
1106 }
1107
1108 #[test]
1109 fn returns_all_available_snapshots_when_count_exceeds_available() {
1110 let db_dir = temp_dir_create!();
1111 let ledger_dir = db_dir.join(LEDGER_DIR);
1112 create_dir(&ledger_dir).unwrap();
1113
1114 create_dir(ledger_dir.join("1000")).unwrap();
1115 create_dir(ledger_dir.join("1500")).unwrap();
1116
1117 let found = SnapshotConverterCommand::find_most_recent_snapshots(&db_dir, 99).unwrap();
1118
1119 assert_eq!(
1120 found,
1121 vec![ledger_dir.join("1500"), ledger_dir.join("1000")]
1122 );
1123 }
1124
1125 #[test]
1126 fn returns_error_if_no_valid_snapshot_found() {
1127 let temp_dir = temp_dir_create!();
1128 let ledger_dir = temp_dir.join(LEDGER_DIR);
1129 create_dir(&ledger_dir).unwrap();
1130
1131 File::create(ledger_dir.join("invalid")).unwrap();
1132
1133 SnapshotConverterCommand::find_most_recent_snapshots(&temp_dir, 2)
1134 .expect_err("Should return error if no valid ledger snapshot directory found");
1135 }
1136
1137 #[test]
1138 fn get_sorted_snapshot_dirs_returns_sorted_valid_directories() {
1139 let temp_dir = temp_dir_create!();
1140 let ledger_dir = temp_dir.join(LEDGER_DIR);
1141 create_dir(&ledger_dir).unwrap();
1142
1143 create_dir(ledger_dir.join("1500")).unwrap();
1144 create_dir(ledger_dir.join("1000")).unwrap();
1145 create_dir(ledger_dir.join("2000")).unwrap();
1146 File::create(ledger_dir.join("500")).unwrap();
1147 create_dir(ledger_dir.join("notanumber")).unwrap();
1148
1149 let snapshots =
1150 SnapshotConverterCommand::get_sorted_snapshot_dirs(&ledger_dir).unwrap();
1151
1152 assert_eq!(
1153 snapshots,
1154 vec![
1155 (1000, ledger_dir.join("1000")),
1156 (1500, ledger_dir.join("1500")),
1157 (2000, ledger_dir.join("2000")),
1158 ]
1159 );
1160 }
1161 }
1162
1163 mod snapshot_conversion_fallback {
1164 use mockall::predicate::{self, always};
1165
1166 use super::*;
1167
1168 fn create_dummy_snapshots(dir: &Path, count: usize) -> Vec<PathBuf> {
1169 (1..=count)
1170 .map(|i| {
1171 let snapshot_path = dir.join(format!("{i}"));
1172 create_dir(&snapshot_path).unwrap();
1173 snapshot_path
1174 })
1175 .collect()
1176 }
1177
1178 fn contains_filename(expected: &Path) -> impl Fn(&Path) -> bool + use<> {
1179 let filename = expected.file_name().unwrap().to_string_lossy().to_string();
1180
1181 move |p: &Path| p.to_string_lossy().contains(&filename)
1182 }
1183
1184 #[test]
1185 fn conversion_succeed_and_is_called_once_if_first_conversion_succeeds() {
1186 let temp_dir = temp_dir_create!();
1187 let snapshots = create_dummy_snapshots(&temp_dir, 2);
1188
1189 let mut converter = MockSnapshotConverter::new();
1190 converter
1191 .expect_convert()
1192 .with(
1193 predicate::function(contains_filename(&snapshots[0])),
1194 always(),
1195 )
1196 .return_once(|_, _| Ok(()))
1197 .once();
1198
1199 SnapshotConverterCommand::try_convert(
1200 Path::new(""),
1201 &UTxOHDFlavor::Lmdb,
1202 &snapshots,
1203 Box::new(converter),
1204 )
1205 .unwrap();
1206 }
1207
1208 #[test]
1209 fn conversion_falls_back_if_first_conversion_fails() {
1210 let temp_dir = temp_dir_create!();
1211 let snapshots = create_dummy_snapshots(&temp_dir, 2);
1212
1213 let mut converter = MockSnapshotConverter::new();
1214 converter
1215 .expect_convert()
1216 .with(
1217 predicate::function(contains_filename(&snapshots[0])),
1218 always(),
1219 )
1220 .return_once(|_, _| Err(anyhow!("Error during conversion")))
1221 .once();
1222 converter
1223 .expect_convert()
1224 .with(
1225 predicate::function(contains_filename(&snapshots[1])),
1226 always(),
1227 )
1228 .return_once(|_, _| Ok(()))
1229 .once();
1230
1231 SnapshotConverterCommand::try_convert(
1232 Path::new(""),
1233 &UTxOHDFlavor::Lmdb,
1234 &snapshots,
1235 Box::new(converter),
1236 )
1237 .expect("Should succeed even if the first conversion fails");
1238 }
1239
1240 #[test]
1241 fn conversion_fails_if_all_attempts_fail() {
1242 let temp_dir = temp_dir_create!();
1243 let snapshots = create_dummy_snapshots(&temp_dir, 2);
1244 let mut converter = MockSnapshotConverter::new();
1245 converter
1246 .expect_convert()
1247 .returning(|_, _| Err(anyhow!("Conversion failed")))
1248 .times(2);
1249
1250 SnapshotConverterCommand::try_convert(
1251 Path::new(""),
1252 &UTxOHDFlavor::Lmdb,
1253 &snapshots,
1254 Box::new(converter),
1255 )
1256 .expect_err("Should fail if all conversion attempts fail");
1257 }
1258 }
1259}