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