1,620 changes: 1,014 additions & 606 deletions resources/latest_release_response_body.json

Large diffs are not rendered by default.

60 changes: 30 additions & 30 deletions resources/release_with_no_assets_response_body.json

Large diffs are not rendered by default.

198 changes: 180 additions & 18 deletions src/github.rs
Expand Up @@ -64,24 +64,39 @@ impl GithubReleaseRepository {
.ok_or_else(|| eyre!("Response body does not contain 'tag_name' value"))?;
let version = self.get_version_from_tag_name(&asset_type, tag_name)?;

let asset_name = match asset_type {
AssetType::Client => format!("sn_cli-{version}-{platform}.tar.gz"),
AssetType::Node => {
format!("sn_node-{version}-{platform}.tar.gz")
}
};
let asset_name = self.get_versioned_asset_name(&asset_type, platform, &version);
if self.release_has_asset(&json, &asset_name)? {
return Ok((asset_name, version));
}

let msg = match asset_type {
AssetType::Client => format!("Release has no client asset for platform {platform}"),
AssetType::Node => format!("Release has no node asset for platform {platform}"),
AssetType::Client => {
format!("Release v{version} has no client asset for platform {platform}")
}
AssetType::Node => {
format!("Release v{version} has no node asset for platform {platform}")
}
AssetType::Testnet => {
format!("Release v{version} has no testnet asset for platform {platform}")
}
};

Err(eyre!(msg))
}

pub fn get_versioned_asset_name(
&self,
asset_type: &AssetType,
platform: &str,
version: &str,
) -> String {
match asset_type {
AssetType::Client => format!("safe-{version}-{platform}.tar.gz"),
AssetType::Node => format!("safenode-{version}-{platform}.tar.gz"),
AssetType::Testnet => format!("testnet-{version}-{platform}.tar.gz"),
}
}

fn release_has_asset(&self, json: &Value, asset_name: &str) -> Result<bool> {
let assets = json["assets"]
.as_array()
Expand All @@ -99,10 +114,19 @@ impl GithubReleaseRepository {
fn get_version_from_tag_name(&self, asset_type: &AssetType, tag_name: &str) -> Result<String> {
let mut parts = tag_name.split('-');
let version = match asset_type {
AssetType::Client => parts
.last()
.ok_or_else(|| eyre!("Could not parse version from tag_name"))?
.to_string(),
AssetType::Client => {
parts.next();
parts.next();
parts.next();
parts.next();
parts.next();
parts.next();
parts.next();
parts
.next()
.ok_or_else(|| eyre!("Could not parse version from tag_name"))?
.to_string()
}
AssetType::Node => {
parts.next();
parts.next();
Expand All @@ -114,6 +138,10 @@ impl GithubReleaseRepository {
.ok_or_else(|| eyre!("Could not parse version from tag_name"))?
.to_string()
}
AssetType::Testnet => parts
.last()
.ok_or_else(|| eyre!("Could not parse version from tag_name"))?
.to_string(),
};
Ok(version)
}
Expand Down Expand Up @@ -147,8 +175,8 @@ mod test {
.await?;

latest_release_mock.assert();
assert_eq!(asset_name, "sn_cli-0.72.1-x86_64-unknown-linux-musl.tar.gz");
assert_eq!(version, "0.72.1");
assert_eq!(asset_name, "safe-0.74.2-x86_64-unknown-linux-musl.tar.gz");
assert_eq!(version, "0.74.2");
Ok(())
}

Expand Down Expand Up @@ -178,7 +206,7 @@ mod test {
Err(msg) => {
assert_eq!(
msg.to_string(),
"Release has no client asset for platform x86_64-unknown-linux-musl"
"Release v0.74.2 has no client asset for platform x86_64-unknown-linux-musl"
);
Ok(())
}
Expand Down Expand Up @@ -210,9 +238,9 @@ mod test {
latest_release_mock.assert();
assert_eq!(
asset_name,
"sn_node-0.77.6-x86_64-unknown-linux-musl.tar.gz"
"safenode-0.80.1-x86_64-unknown-linux-musl.tar.gz"
);
assert_eq!(version, "0.77.6");
assert_eq!(version, "0.80.1");
Ok(())
}

Expand Down Expand Up @@ -242,7 +270,7 @@ mod test {
Err(msg) => {
assert_eq!(
msg.to_string(),
"Release has no node asset for platform x86_64-unknown-linux-musl"
"Release v0.80.1 has no node asset for platform x86_64-unknown-linux-musl"
);
Ok(())
}
Expand Down Expand Up @@ -280,4 +308,138 @@ mod test {
Ok(_) => Err(eyre!("This test case is expected to return an error")),
}
}

#[tokio::test]
async fn get_latest_asset_name_for_testnet_should_get_asset_name_with_the_latest_version(
) -> Result<()> {
let server = MockServer::start();
let response_body = std::fs::read_to_string(
std::path::Path::new("resources").join("latest_release_response_body.json"),
)?;
let latest_release_mock = server.mock(|when, then| {
when.method(GET)
.path("/repos/maidsafe/safe_network/releases/latest");
then.status(200)
.header("server", "Github.com")
.body(response_body);
});

let repository =
GithubReleaseRepository::new(&server.base_url(), "maidsafe", "safe_network");
let (asset_name, version) = repository
.get_latest_asset_name(AssetType::Testnet, "x86_64-unknown-linux-musl")
.await?;

latest_release_mock.assert();
assert_eq!(asset_name, "testnet-0.1.3-x86_64-unknown-linux-musl.tar.gz");
assert_eq!(version, "0.1.3");
Ok(())
}

#[tokio::test]
async fn get_latest_asset_name_for_testnet_should_return_error_when_release_has_no_asset(
) -> Result<()> {
let server = MockServer::start();
let response_body = std::fs::read_to_string(
std::path::Path::new("resources").join("release_with_no_assets_response_body.json"),
)?;
let latest_release_mock = server.mock(|when, then| {
when.method(GET)
.path("/repos/maidsafe/safe_network/releases/latest");
then.status(200)
.header("server", "Github.com")
.body(response_body);
});

let repository =
GithubReleaseRepository::new(&server.base_url(), "maidsafe", "safe_network");
let result = repository
.get_latest_asset_name(AssetType::Testnet, "x86_64-unknown-linux-musl")
.await;

latest_release_mock.assert();
match result {
Err(msg) => {
assert_eq!(
msg.to_string(),
"Release v0.1.3 has no testnet asset for platform x86_64-unknown-linux-musl"
);
Ok(())
}
Ok(_) => Err(eyre!("This test case is expected to return an error")),
}
}

/// This test case is slightly different from the node and client because when you call `split`
/// to get the version numbers, in the case of testnet, we are using `last`, which will have a
/// value.
#[tokio::test]
async fn get_latest_asset_name_for_testnet_should_return_error_when_release_has_invalid_tag_name(
) -> Result<()> {
let server = MockServer::start();
let response_body = std::fs::read_to_string(
std::path::Path::new("resources").join("release_with_invalid_tag_name.json"),
)?;
let latest_release_mock = server.mock(|when, then| {
when.method(GET)
.path("/repos/maidsafe/safe_network/releases/latest");
then.status(200)
.header("server", "Github.com")
.body(response_body);
});

let repository =
GithubReleaseRepository::new(&server.base_url(), "maidsafe", "safe_network");
let result = repository
.get_latest_asset_name(AssetType::Testnet, "x86_64-unknown-linux-musl")
.await;

latest_release_mock.assert();
match result {
Err(msg) => {
assert_eq!(
msg.to_string(),
"Release v0.1.0 has no testnet asset for platform x86_64-unknown-linux-musl"
);
Ok(())
}
Ok(_) => Err(eyre!("This test case is expected to return an error")),
}
}

#[test]
fn get_versioned_asset_name_should_return_client_asset_name() -> Result<()> {
let repository = GithubReleaseRepository::new("localhost", "maidsafe", "safe_network");
let result = repository.get_versioned_asset_name(
&AssetType::Client,
"x86_64-unknown-linux-musl",
"0.74.2",
);
assert_eq!(result, "safe-0.74.2-x86_64-unknown-linux-musl.tar.gz");
Ok(())
}

#[test]
fn get_versioned_asset_name_should_return_node_asset_name() -> Result<()> {
let repository = GithubReleaseRepository::new("localhost", "maidsafe", "safe_network");
let result = repository.get_versioned_asset_name(
&AssetType::Node,
"x86_64-unknown-linux-musl",
"0.80.4",
);
assert_eq!(result, "safenode-0.80.4-x86_64-unknown-linux-musl.tar.gz");
Ok(())
}

#[test]
fn get_versioned_asset_name_should_return_testnet_asset_name() -> Result<()> {
let repository = GithubReleaseRepository::new("localhost", "maidsafe", "safe_network");
let result = repository.get_versioned_asset_name(
&AssetType::Testnet,
"x86_64-unknown-linux-musl",
"0.1.3",
);
assert_eq!(result, "testnet-0.1.3-x86_64-unknown-linux-musl.tar.gz");
Ok(())
}
}
443 changes: 405 additions & 38 deletions src/install.rs

Large diffs are not rendered by default.

223 changes: 181 additions & 42 deletions src/main.rs
Expand Up @@ -13,15 +13,17 @@ mod s3;
use clap::{Parser, Subcommand};
use color_eyre::{eyre::eyre, Result};
use github::GithubReleaseRepository;
use install::AssetType;
use install::{AssetType, Settings};
use s3::S3AssetRepository;
use std::env::consts::{ARCH, OS};
use std::path::PathBuf;
use std::path::{Path, PathBuf};

const GITHUB_API_URL: &str = "https://api.github.com";
const ORG_NAME: &str = "maidsafe";
const REPO_NAME: &str = "safe_network";
const SAFE_BUCKET_NAME: &str = "https://sn-cli.s3.eu-west-2.amazonaws.com";
const SAFENODE_BUCKET_NAME: &str = "https://sn-node.s3.eu-west-2.amazonaws.com";
const TESTNET_BUCKET_NAME: &str = "https://sn-testnet.s3.eu-west-2.amazonaws.com";

#[derive(Parser)]
#[command(version, about)]
Expand All @@ -36,85 +38,205 @@ enum Commands {
///
/// The default install path is /usr/local/bin if you run safeup as root, or ~/.safe/cli if you
/// run as the current user.
///
/// If running as the current user, the shell profile will be modified to put safe on PATH.
Client {
/// Override the default installation path.
///
/// Any directories that don't exist will be created.
#[arg(short, long, value_name = "DIRECTORY")]
#[arg(short = 'p', long, value_name = "DIRECTORY")]
path: Option<PathBuf>,

/// Disable modification of the shell profile.
#[arg(short = 'n', long)]
no_modify_shell_profile: bool,

/// Install a specific version rather than the latest.
#[arg(short = 'v', long)]
version: Option<String>,
},
/// Install the latest version of safenode.
///
/// The default install path is /usr/local/bin if you run safeup as root, or ~/.safe/node if you
/// run as the current user.
///
/// If running as the current user, the shell profile will be modified to put safe on PATH.
Node {
/// Override the default installation path.
///
/// Any directories that don't exist will be created.
#[arg(short = 'p', long, value_name = "DIRECTORY")]
path: Option<PathBuf>,

/// Disable modification of the shell profile.
#[arg(short = 'n', long)]
no_modify_shell_profile: bool,

/// Install a specific version rather than the latest.
#[arg(short = 'v', long)]
version: Option<String>,
},
/// Install the latest version of testnet.
///
/// The default install path is /usr/local/bin if you run safeup as root, or ~/.safe/node if you
/// run as the current user.
///
/// If running as the current user, the shell profile will be modified to put safe on PATH.
Testnet {
/// Override the default installation path.
///
/// Any directories that don't exist will be created.
#[arg(short = 'p', long, value_name = "DIRECTORY")]
path: Option<PathBuf>,

/// Disable modification of the shell profile.
#[arg(short = 'n', long)]
no_modify_shell_profile: bool,

/// Install a specific version rather than the latest.
#[arg(short = 'v', long)]
version: Option<String>,
},
}

#[tokio::main]
async fn main() -> Result<()> {
let platform = get_platform()?;
let cli = Cli::parse();
let result = match cli.command {
Some(Commands::Client { path }) => {
let dest_dir_path = if let Some(path) = path {
path
} else {
if is_running_elevated() {
std::path::PathBuf::from("/usr/local/bin")
} else {
let home_dir_path = dirs_next::home_dir()
.ok_or_else(|| eyre!("Could not retrieve user's home directory"))?;
home_dir_path.join(".safe").join("cli")
}
};
let release_repository =
GithubReleaseRepository::new(GITHUB_API_URL, ORG_NAME, REPO_NAME);
let asset_repository = S3AssetRepository::new(SAFE_BUCKET_NAME);
install::install_bin(
match cli.command {
Some(Commands::Client {
path,
no_modify_shell_profile,
version,
}) => {
install(
AssetType::Client,
release_repository,
asset_repository,
&platform,
dest_dir_path.clone(),
SAFE_BUCKET_NAME,
path,
version,
no_modify_shell_profile,
)
.await?;
Ok(())
.await
}
Some(Commands::Node {
path,
no_modify_shell_profile,
version,
}) => {
install(
AssetType::Node,
SAFENODE_BUCKET_NAME,
path,
version,
no_modify_shell_profile,
)
.await
}
Some(Commands::Testnet {
path,
no_modify_shell_profile,
version,
}) => {
install(
AssetType::Testnet,
TESTNET_BUCKET_NAME,
path,
version,
no_modify_shell_profile,
)
.await
}
None => {
println!("interactive gui");
Ok(())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small suggestion, but can we maybe print the help screen if no args were passed? https://docs.rs/clap/latest/clap/struct.Command.html#method.print_help

Copy link
Contributor Author

@jacderida jacderida Mar 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Roland, thanks for the feedback. My plan was that if no arguments were passed, we would run an interactive GUI, which is what rustup does too. Well, sorry, just to clarify, it's not a GUI, I guess, but a TUI.

Do you think that's not worth doing?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see! That would be pretty useful to the non-tech people I assume. But I'm not so sure, they will again be exposed to the techy CLIs of safenode/safe/testnet after using this tool.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, to be honest, the value of it may be quite low. I might ask this question on the forum.

}
}

async fn install(
asset_type: AssetType,
bucket_name: &str,
path: Option<PathBuf>,
version: Option<String>,
no_modify_shell_profile: bool,
) -> Result<()> {
let platform = get_platform()?;
let running_elevated = is_running_elevated();
let home_dir_path =
dirs_next::home_dir().ok_or_else(|| eyre!("Could not retrieve user's home directory"))?;
let safe_dir_path = home_dir_path.join(".safe");
let dest_dir_path = if let Some(path) = path {
path
} else if running_elevated {
std::path::PathBuf::from("/usr/local/bin")
} else {
let dir = match asset_type {
AssetType::Client => "cli",
AssetType::Node => "node",
AssetType::Testnet => "node",
};
safe_dir_path.join(dir)
};
result

let release_repository = GithubReleaseRepository::new(GITHUB_API_URL, ORG_NAME, REPO_NAME);
let asset_repository = S3AssetRepository::new(bucket_name);
let (_, bin_path) = install::install_bin(
asset_type.clone(),
release_repository,
asset_repository,
&platform,
dest_dir_path.clone(),
version,
)
.await?;

if !running_elevated && !no_modify_shell_profile {
install::configure_shell_profile(
&get_shell_profile_path(&home_dir_path),
&home_dir_path.join(".safe").join("env"),
)
.await?
}

let settings_file_path = safe_dir_path.join("safeup.json");
let mut settings = Settings::read(&settings_file_path)?;
match asset_type {
AssetType::Client => settings.safe_path = bin_path,
AssetType::Node => settings.safenode_path = bin_path,
AssetType::Testnet => settings.testnet_path = bin_path,
}
settings.save(&settings_file_path)?;

Ok(())
}

fn get_platform() -> Result<String> {
match OS {
"linux" => match ARCH {
"x86_64" => return Ok(format!("{}-unknown-{}-musl", ARCH, OS)),
"armv7" => return Ok(format!("{}-unknown-{}-musleabihf", ARCH, OS)),
"arm" => return Ok(format!("{}-unknown-{}-musleabi", ARCH, OS)),
"aarch64" => return Ok(format!("{}-unknown-{}-musl", ARCH, OS)),
&_ => {
return Err(eyre!(
"We currently do not have binaries for the {OS}/{ARCH} combination"
))
}
"x86_64" => Ok(format!("{}-unknown-{}-musl", ARCH, OS)),
"armv7" => Ok(format!("{}-unknown-{}-musleabihf", ARCH, OS)),
"arm" => Ok(format!("{}-unknown-{}-musleabi", ARCH, OS)),
"aarch64" => Ok(format!("{}-unknown-{}-musl", ARCH, OS)),
&_ => Err(eyre!(
"We currently do not have binaries for the {OS}/{ARCH} combination"
)),
},
"windows" => {
if ARCH != "x86_64" {
return Err(eyre!(
"We currently only have x86_64 binaries available for Windows"
));
}
return Ok(format!("{}-pc-{}-msvc", ARCH, OS));
Ok(format!("{}-pc-{}-msvc", ARCH, OS))
}
"macos" => {
if ARCH != "x86_64" {
return Err(eyre!(
"We currently only have x86_64 binaries available for macOS"
));
}
return Ok(format!("{}-apple-darwin", ARCH));
}
&_ => {
return Err(eyre!("{OS} is not currently supported by safeup"));
Ok(format!("{}-apple-darwin", ARCH))
}
&_ => Err(eyre!("{OS} is not currently supported by safeup")),
}
}

Expand All @@ -134,3 +256,20 @@ fn is_running_elevated() -> bool {
fn is_running_elevated() -> bool {
false
}

#[cfg(target_os = "linux")]
fn get_shell_profile_path(home_dir_path: &Path) -> PathBuf {
home_dir_path.join(".bashrc")
}

/// We won't actually end up doing anything on Windows with the shell profile, so we can just
/// return back the home directory.
#[cfg(target_os = "windows")]
fn get_shell_profile_path(home_dir_path: &Path) -> PathBuf {
home_dir_path.to_path_buf()
}

#[cfg(target_os = "macos")]
fn get_shell_profile_path(home_dir_path: &Path) -> PathBuf {
home_dir_path.join(".zshrc")
}
6 changes: 3 additions & 3 deletions src/s3.rs
Expand Up @@ -83,7 +83,7 @@ mod test {
let tmp_data_path = assert_fs::TempDir::new()?;
let safe_archive = tmp_data_path.child("safe.tar.gz");
let downloaded_safe_archive =
tmp_data_path.child("sn_cli-0.72.1-x86_64-unknown-linux-musl.tar.gz");
tmp_data_path.child("safe-0.72.1-x86_64-unknown-linux-musl.tar.gz");
let fake_safe_bin = tmp_data_path.child("safe");
fake_safe_bin.write_binary(b"fake code")?;

Expand All @@ -97,7 +97,7 @@ mod test {
let server = MockServer::start();
let download_asset_mock = server.mock(|when, then| {
when.method(GET)
.path("/sn_cli-0.72.1-x86_64-unknown-linux-musl.tar.gz");
.path("/safe-0.72.1-x86_64-unknown-linux-musl.tar.gz");
then.status(200)
.header("Content-Length", safe_archive_metadata.len().to_string())
.header("Content-Type", "application/gzip")
Expand All @@ -107,7 +107,7 @@ mod test {
let repository = S3AssetRepository::new(&server.base_url());
repository
.download_asset(
"sn_cli-0.72.1-x86_64-unknown-linux-musl.tar.gz",
"safe-0.72.1-x86_64-unknown-linux-musl.tar.gz",
&downloaded_safe_archive.path().to_path_buf(),
)
.await?;
Expand Down