Skip to content

Commit 5db37b5

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

File tree

10 files changed

+568
-4
lines changed

10 files changed

+568
-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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use anyhow::{anyhow, Context};
2+
use async_trait::async_trait;
3+
use reqwest::{Client, IntoUrl};
4+
use serde::de::DeserializeOwned;
5+
6+
use mithril_client::MithrilResult;
7+
8+
use super::{GitHubRelease, GitHubReleaseRetriever};
9+
10+
pub struct ReqwestGitHubApiClient {
11+
client: Client,
12+
}
13+
14+
impl ReqwestGitHubApiClient {
15+
pub fn new() -> MithrilResult<Self> {
16+
let client = Client::builder()
17+
.user_agent("mithril-client")
18+
.build()
19+
.context("Failed to build Reqwest GitHub API client")?;
20+
21+
Ok(Self { client })
22+
}
23+
24+
async fn download<U: IntoUrl, T: DeserializeOwned>(&self, source_url: U) -> MithrilResult<T> {
25+
let url = source_url
26+
.into_url()
27+
.with_context(|| "Given `source_url` is not a valid Url")?;
28+
let response = self
29+
.client
30+
.get(url.clone())
31+
.send()
32+
.await
33+
.with_context(|| format!("Failed to send request to GitHub API: {}", url))?;
34+
let body = response.text().await?;
35+
let parsed_body = serde_json::from_str::<T>(&body)
36+
.with_context(|| format!("Failed to parse response from GitHub API: {:?}", body))?;
37+
38+
Ok(parsed_body)
39+
}
40+
}
41+
42+
#[async_trait]
43+
impl GitHubReleaseRetriever for ReqwestGitHubApiClient {
44+
async fn get_release_by_tag(
45+
&self,
46+
organization: &str,
47+
repository: &str,
48+
tag: &str,
49+
) -> MithrilResult<GitHubRelease> {
50+
let url =
51+
format!("https://api.github.com/repos/{organization}/{repository}/releases/tags/{tag}");
52+
let release = self.download(url).await?;
53+
54+
Ok(release)
55+
}
56+
57+
async fn get_latest_release(
58+
&self,
59+
organization: &str,
60+
repository: &str,
61+
) -> MithrilResult<GitHubRelease> {
62+
let url =
63+
format!("https://api.github.com/repos/{organization}/{repository}/releases/latest");
64+
let release = self.download(url).await?;
65+
66+
Ok(release)
67+
}
68+
69+
async fn get_prerelease(
70+
&self,
71+
organization: &str,
72+
repository: &str,
73+
) -> MithrilResult<GitHubRelease> {
74+
let releases = self.get_all_releases(organization, repository).await?;
75+
let prerelease = releases
76+
.into_iter()
77+
.find(|release| release.prerelease)
78+
.ok_or_else(|| anyhow!("No prerelease found"))?;
79+
80+
Ok(prerelease)
81+
}
82+
83+
async fn get_all_releases(
84+
&self,
85+
organization: &str,
86+
repository: &str,
87+
) -> MithrilResult<Vec<GitHubRelease>> {
88+
let url = format!("https://api.github.com/repos/{organization}/{repository}/releases");
89+
let releases = self.download(url).await?;
90+
91+
Ok(releases)
92+
}
93+
}
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_file(
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_file(
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)