Skip to content

Commit b21fe4c

Browse files
committed
feat(client-cli): implement download of Cardano node distribution to retrieve the snapshot-converter binary
1 parent ea657fb commit b21fe4c

File tree

10 files changed

+587
-4
lines changed

10 files changed

+587
-4
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mithril-client-cli/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ indicatif = { version = "0.17.11", features = ["tokio"] }
3838
mithril-cli-helper = { path = "../internal/mithril-cli-helper" }
3939
mithril-client = { path = "../mithril-client", features = ["fs", "unstable"] }
4040
mithril-doc = { path = "../internal/mithril-doc" }
41+
reqwest = { workspace = true, features = [
42+
"default",
43+
"gzip",
44+
"zstd",
45+
"deflate",
46+
"brotli"
47+
] }
4148
serde = { workspace = true }
4249
serde_json = { workspace = true }
4350
slog = { workspace = true, features = [
@@ -52,3 +59,4 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
5259

5360
[dev-dependencies]
5461
mithril-common = { path = "../mithril-common", features = ["test_tools"] }
62+
mockall = { workspace = true }
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use anyhow::anyhow;
2+
use serde::Deserialize;
3+
4+
use mithril_client::MithrilResult;
5+
6+
pub const ASSET_PLATFORM_LINUX: &str = "linux";
7+
pub const ASSET_PLATFORM_MACOS: &str = "macos";
8+
pub const ASSET_PLATFORM_WINDOWS: &str = "win64";
9+
10+
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
11+
pub struct GitHubAsset {
12+
pub name: String,
13+
pub browser_download_url: String,
14+
}
15+
16+
#[derive(Debug, Default, Clone, Deserialize)]
17+
pub struct GitHubRelease {
18+
pub assets: Vec<GitHubAsset>,
19+
pub prerelease: bool,
20+
}
21+
22+
impl GitHubRelease {
23+
pub fn get_asset_for_os(&self, target_os: &str) -> MithrilResult<Option<&GitHubAsset>> {
24+
let os_in_asset_name = match target_os {
25+
"linux" => ASSET_PLATFORM_LINUX,
26+
"macos" => ASSET_PLATFORM_MACOS,
27+
"windows" => ASSET_PLATFORM_WINDOWS,
28+
_ => return Err(anyhow!("Unsupported platform: {}", target_os)),
29+
};
30+
31+
let asset = self
32+
.assets
33+
.iter()
34+
.find(|asset| asset.name.contains(os_in_asset_name));
35+
36+
Ok(asset)
37+
}
38+
39+
#[cfg(test)]
40+
pub fn dummy_with_all_supported_assets() -> Self {
41+
GitHubRelease {
42+
assets: vec![
43+
GitHubAsset {
44+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_LINUX),
45+
browser_download_url: "https://release-assets.com/linux".to_string(),
46+
},
47+
GitHubAsset {
48+
name: format!("asset-name-{}.tar.gz", ASSET_PLATFORM_MACOS),
49+
browser_download_url: "https://release-assets.com/macos".to_string(),
50+
},
51+
GitHubAsset {
52+
name: format!("asset-name-{}.zip", ASSET_PLATFORM_WINDOWS),
53+
browser_download_url: "https://release-assets.com/windows".to_string(),
54+
},
55+
],
56+
..GitHubRelease::default()
57+
}
58+
}
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
64+
use super::*;
65+
66+
fn dummy_asset(os: &str) -> GitHubAsset {
67+
GitHubAsset {
68+
name: format!("asset-name-{}.whatever", os),
69+
browser_download_url: format!("https://release-assets.com/{}", os),
70+
}
71+
}
72+
73+
#[test]
74+
fn returns_expected_asset_for_each_supported_platform() {
75+
let release = GitHubRelease {
76+
assets: vec![
77+
dummy_asset(ASSET_PLATFORM_LINUX),
78+
dummy_asset(ASSET_PLATFORM_MACOS),
79+
dummy_asset(ASSET_PLATFORM_WINDOWS),
80+
],
81+
..GitHubRelease::default()
82+
};
83+
84+
{
85+
let asset = release.get_asset_for_os("linux").unwrap();
86+
assert_eq!(asset, Some(&dummy_asset(ASSET_PLATFORM_LINUX)));
87+
}
88+
89+
{
90+
let asset = release.get_asset_for_os("macos").unwrap();
91+
assert_eq!(asset, Some(&dummy_asset(ASSET_PLATFORM_MACOS)));
92+
}
93+
94+
{
95+
let asset = release.get_asset_for_os("windows").unwrap();
96+
assert_eq!(asset, Some(&dummy_asset(ASSET_PLATFORM_WINDOWS)));
97+
}
98+
}
99+
100+
#[test]
101+
fn returns_none_when_asset_is_missing() {
102+
let release = GitHubRelease {
103+
assets: vec![dummy_asset(ASSET_PLATFORM_LINUX)],
104+
..GitHubRelease::default()
105+
};
106+
107+
let asset = release.get_asset_for_os("macos").unwrap();
108+
109+
assert!(asset.is_none());
110+
}
111+
112+
#[test]
113+
fn fails_for_unsupported_platform() {
114+
let release = GitHubRelease {
115+
assets: vec![dummy_asset(ASSET_PLATFORM_LINUX)],
116+
..GitHubRelease::default()
117+
};
118+
119+
release
120+
.get_asset_for_os("unsupported")
121+
.expect_err("Should have failed for unsupported platform");
122+
}
123+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use async_trait::async_trait;
2+
3+
use mithril_client::MithrilResult;
4+
5+
use super::github_release::GitHubRelease;
6+
7+
/// Trait for interacting with the GitHub API to retrieve Cardano node release.
8+
#[cfg_attr(test, mockall::automock)]
9+
#[async_trait]
10+
pub trait GitHubReleaseRetriever {
11+
/// Retrieves a release by its tag.
12+
async fn get_release_by_tag(
13+
&self,
14+
owner: &str,
15+
repo: &str,
16+
tag: &str,
17+
) -> MithrilResult<GitHubRelease>;
18+
19+
/// Retrieves the latest release.
20+
async fn get_latest_release(&self, owner: &str, repo: &str) -> MithrilResult<GitHubRelease>;
21+
22+
/// Retrieves the prerelease.
23+
async fn get_prerelease(&self, owner: &str, repo: &str) -> MithrilResult<GitHubRelease>;
24+
25+
/// Retrieves all available releases.
26+
async fn get_all_releases(&self, owner: &str, repo: &str) -> MithrilResult<Vec<GitHubRelease>>;
27+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mod github_release;
2+
mod interface;
3+
mod reqwest;
4+
5+
pub use github_release::*;
6+
pub use interface::*;
7+
pub use reqwest::*;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
use anyhow::{anyhow, Context};
2+
use async_trait::async_trait;
3+
use reqwest::{Client, Url};
4+
5+
use mithril_client::MithrilResult;
6+
7+
use super::{GitHubRelease, GitHubReleaseRetriever};
8+
9+
pub struct ReqwestGitHubApiClient {
10+
client: Client,
11+
}
12+
13+
impl ReqwestGitHubApiClient {
14+
pub fn new() -> MithrilResult<Self> {
15+
let client = Client::builder()
16+
.user_agent("mithril-client")
17+
.build()
18+
.context("Failed to build Reqwest GitHub API client")?;
19+
20+
Ok(Self { client })
21+
}
22+
}
23+
24+
#[async_trait]
25+
impl GitHubReleaseRetriever for ReqwestGitHubApiClient {
26+
async fn get_release_by_tag(
27+
&self,
28+
organization: &str,
29+
repository: &str,
30+
tag: &str,
31+
) -> MithrilResult<GitHubRelease> {
32+
let url =
33+
format!("https://api.github.com/repos/{organization}/{repository}/releases/tags/{tag}");
34+
let url = Url::parse(&url)
35+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
36+
37+
let response = self
38+
.client
39+
.get(url.clone())
40+
.send()
41+
.await
42+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
43+
44+
let response = response.text().await?;
45+
let release: GitHubRelease = serde_json::from_str(&response)
46+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
47+
48+
Ok(release)
49+
}
50+
51+
async fn get_latest_release(
52+
&self,
53+
organization: &str,
54+
repository: &str,
55+
) -> MithrilResult<GitHubRelease> {
56+
let url =
57+
format!("https://api.github.com/repos/{organization}/{repository}/releases/latest");
58+
let url = Url::parse(&url)
59+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
60+
61+
let response = self
62+
.client
63+
.get(url.clone())
64+
.send()
65+
.await
66+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
67+
68+
let response = response.text().await?;
69+
let release: GitHubRelease = serde_json::from_str(&response)
70+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
71+
72+
Ok(release)
73+
}
74+
75+
async fn get_prerelease(
76+
&self,
77+
organization: &str,
78+
repository: &str,
79+
) -> MithrilResult<GitHubRelease> {
80+
let releases = self.get_all_releases(organization, repository).await?;
81+
82+
let prerelease = releases
83+
.into_iter()
84+
.find(|release| release.prerelease)
85+
.ok_or_else(|| anyhow!("No prerelease found"))?;
86+
87+
Ok(prerelease)
88+
}
89+
90+
async fn get_all_releases(
91+
&self,
92+
organization: &str,
93+
repository: &str,
94+
) -> MithrilResult<Vec<GitHubRelease>> {
95+
let url = format!("https://api.github.com/repos/{organization}/{repository}/releases");
96+
let url = Url::parse(&url)
97+
.with_context(|| format!("Failed to parse URL for GitHub API: {}", url))?;
98+
99+
let response = self
100+
.client
101+
.get(url.clone())
102+
.send()
103+
.await
104+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
105+
106+
let response = response.text().await?;
107+
let releases: Vec<GitHubRelease> = serde_json::from_str(&response)
108+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", response))?;
109+
110+
Ok(releases)
111+
}
112+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
mod reqwest_http_downloader;
2+
3+
pub use reqwest_http_downloader::*;
4+
5+
use async_trait::async_trait;
6+
use mithril_client::MithrilResult;
7+
use reqwest::Url;
8+
use std::path::{Path, PathBuf};
9+
10+
/// Trait for downloading a file over HTTP from a URL,
11+
/// saving it to a target directory with the given filename.
12+
///
13+
/// Returns the path to the downloaded file.
14+
#[cfg_attr(test, mockall::automock)]
15+
#[async_trait]
16+
pub trait HttpDownloader {
17+
async fn download(
18+
&self,
19+
url: Url,
20+
download_dir: &Path,
21+
filename: &str,
22+
) -> MithrilResult<PathBuf>;
23+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use std::{
2+
fs::File,
3+
io::Write,
4+
path::{Path, PathBuf},
5+
};
6+
7+
use anyhow::Context;
8+
use async_trait::async_trait;
9+
use reqwest::{Client, Url};
10+
11+
use mithril_client::MithrilResult;
12+
13+
use super::HttpDownloader;
14+
15+
/// [ReqwestHttpDownloader] is an implementation of the [HttpDownloader].
16+
pub struct ReqwestHttpDownloader {
17+
client: Client,
18+
}
19+
20+
impl ReqwestHttpDownloader {
21+
/// Creates a new instance of [ReqwestHttpDownloader].
22+
pub fn new() -> MithrilResult<Self> {
23+
let client = Client::builder()
24+
.build()
25+
.with_context(|| "Failed to build Reqwest HTTP client")?;
26+
27+
Ok(Self { client })
28+
}
29+
}
30+
31+
#[async_trait]
32+
impl HttpDownloader for ReqwestHttpDownloader {
33+
async fn download(
34+
&self,
35+
url: Url,
36+
download_dir: &Path,
37+
filename: &str,
38+
) -> MithrilResult<PathBuf> {
39+
let response = self
40+
.client
41+
.get(url.clone())
42+
.send()
43+
.await
44+
.with_context(|| format!("Failed to download file from URL: {}", url))?;
45+
46+
let bytes = response.bytes().await?;
47+
let download_filepath = download_dir.join(filename);
48+
let mut file = File::create(&download_filepath)?;
49+
file.write_all(&bytes)?;
50+
51+
Ok(download_filepath)
52+
}
53+
}

mithril-client-cli/src/commands/tools/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
//! Provides utility subcommands such as converting restored InMemory UTxO-HD ledger snapshot
44
//! to different flavors (Legacy, LMDB).
55
6+
mod github_release_retriever;
7+
mod http_downloader;
68
mod snapshot_converter;
79

8-
use mithril_client::MithrilResult;
910
pub use snapshot_converter::*;
1011

1112
use clap::Subcommand;
13+
use mithril_client::MithrilResult;
1214

1315
/// Tools commands
1416
#[derive(Subcommand, Debug, Clone)]

0 commit comments

Comments
 (0)