use std::collections::HashMap;
use anyhow::{bail, Result};
use reqwest::StatusCode;
use tokio::fs::{create_dir_all, metadata, File};
use tokio::process::{Child, Command};
use tokio_stream::StreamExt;
use tokio_util::io::StreamReader;
use tracing::warn;
#[cfg(target_family = "unix")]
use std::os::unix::prelude::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
#[cfg(target_family = "unix")]
use command_group::AsyncCommandGroup;
use super::get_download_client;
const WASMCLOUD_GITHUB_RELEASE_URL: &str =
"https://github.com/wasmCloud/wasmCloud/releases/download";
#[cfg(target_family = "unix")]
pub const WASMCLOUD_HOST_BIN: &str = "wasmcloud_host";
#[cfg(target_family = "windows")]
pub const WASMCLOUD_HOST_BIN: &str = "wasmcloud_host.exe";
const MINIMUM_WASMCLOUD_VERSION: &str = "0.81.0";
pub async fn ensure_wasmcloud<P>(version: &str, dir: P) -> Result<PathBuf>
where
P: AsRef<Path>,
{
ensure_wasmcloud_for_os_arch_pair(version, dir).await
}
pub async fn ensure_wasmcloud_for_os_arch_pair<P>(version: &str, dir: P) -> Result<PathBuf>
where
P: AsRef<Path>,
{
check_version(version)?;
if let Some(dir) = find_wasmcloud_binary(&dir, version).await {
return Ok(dir);
}
download_wasmcloud_for_os_arch_pair(version, dir).await
}
pub async fn download_wasmcloud<P>(version: &str, dir: P) -> Result<PathBuf>
where
P: AsRef<Path>,
{
download_wasmcloud_for_os_arch_pair(version, dir).await
}
pub async fn download_wasmcloud_for_os_arch_pair<P>(version: &str, dir: P) -> Result<PathBuf>
where
P: AsRef<Path>,
{
let url = wasmcloud_url(version);
let download_response = get_download_client()?.get(&url).send().await?;
if download_response.status() != StatusCode::OK {
bail!(
"failed to download wasmCloud host from {}. Status code: {}",
url,
download_response.status()
);
}
let burrito_bites_stream = download_response
.bytes_stream()
.map(|result| result.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let mut wasmcloud_host_burrito = StreamReader::new(burrito_bites_stream);
let version_dir = dir.as_ref().join(version);
let file_path = version_dir.join(WASMCLOUD_HOST_BIN);
if let Some(parent_folder) = file_path.parent() {
create_dir_all(parent_folder).await?;
}
if let Ok(mut wasmcloud_file) = File::create(&file_path).await {
if file_path.file_name().is_some() {
#[cfg(target_family = "unix")]
{
let mut perms = wasmcloud_file.metadata().await?.permissions();
perms.set_mode(0o755);
wasmcloud_file.set_permissions(perms).await?;
}
}
tokio::io::copy(&mut wasmcloud_host_burrito, &mut wasmcloud_file).await?;
}
match find_wasmcloud_binary(&dir, version).await {
Some(path) => Ok(path),
None => bail!("wasmCloud was not installed successfully, please see logs"),
}
}
pub async fn start_wasmcloud_host<P, T, S>(
bin_path: P,
stdout: T,
stderr: S,
env_vars: HashMap<String, String>,
) -> Result<Child>
where
P: AsRef<Path>,
T: Into<Stdio>,
S: Into<Stdio>,
{
let mut cmd = Command::new(bin_path.as_ref());
let cmd = cmd
.stderr(stderr)
.stdout(stdout)
.stdin(Stdio::null())
.envs(&env_vars);
#[cfg(target_family = "unix")]
{
Ok(cmd.group_spawn()?.into_inner())
}
#[cfg(target_family = "windows")]
{
Ok(cmd.spawn()?)
}
}
pub async fn find_wasmcloud_binary<P>(dir: P, version: &str) -> Option<PathBuf>
where
P: AsRef<Path>,
{
let versioned_dir = dir.as_ref().join(version);
let bin_file = versioned_dir.join(WASMCLOUD_HOST_BIN);
metadata(&bin_file).await.is_ok().then_some(bin_file)
}
fn wasmcloud_url(version: &str) -> String {
#[cfg(target_os = "android")]
let os = "linux-android";
#[cfg(target_os = "macos")]
let os = "apple-darwin";
#[cfg(all(target_os = "linux", not(target_arch = "riscv64")))]
let os = "unknown-linux-musl";
#[cfg(all(target_os = "linux", target_arch = "riscv64"))]
let os = "unknown-linux-gnu";
#[cfg(target_os = "windows")]
let os = "pc-windows-msvc.exe";
format!(
"{WASMCLOUD_GITHUB_RELEASE_URL}/{version}/wasmcloud-{arch}-{os}",
arch = std::env::consts::ARCH
)
}
fn check_version(version: &str) -> Result<()> {
let version_req = semver::VersionReq::parse(&format!(">={MINIMUM_WASMCLOUD_VERSION}"))?;
match semver::Version::parse(version.trim_start_matches('v')) {
Ok(parsed_version) if !parsed_version.pre.is_empty() => {
warn!("Using prerelease version {} of wasmCloud", version);
Ok(())
}
Ok(parsed_version) if !version_req.matches(&parsed_version) => bail!(
"wasmCloud version {} is earlier than the minimum supported version of v{}",
version,
MINIMUM_WASMCLOUD_VERSION
),
Ok(_ver) => Ok(()),
Err(_parse_err) => {
warn!("Failed to parse wasmCloud version as a semantic version, download may fail");
Ok(())
}
}
}
#[cfg(test)]
mod test {
use super::{check_version, ensure_wasmcloud, wasmcloud_url, MINIMUM_WASMCLOUD_VERSION};
use crate::start::{
ensure_nats_server, ensure_wasmcloud_for_os_arch_pair, find_wasmcloud_binary,
is_bin_installed, start_nats_server, start_wasmcloud_host, NatsConfig, NATS_SERVER_BINARY,
};
use anyhow::{Context, Result};
use reqwest::StatusCode;
use std::net::{Ipv4Addr, SocketAddrV4};
use std::{collections::HashMap, env::temp_dir};
use tokio::fs::{create_dir_all, remove_dir_all};
use tokio::net::TcpListener;
use tokio::time::Duration;
const WASMCLOUD_VERSION: &str = "v0.81.0";
async fn find_open_port() -> Result<u16> {
TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.await
.context("failed to bind random port")?
.local_addr()
.map(|addr| addr.port())
.context("failed to get local address from opened TCP socket")
}
#[tokio::test]
#[cfg_attr(not(can_reach_github_com), ignore = "github.com is not reachable")]
async fn can_request_supported_wasmcloud_urls() {
assert_eq!(
reqwest::get(wasmcloud_url(WASMCLOUD_VERSION))
.await
.unwrap()
.status(),
StatusCode::OK
);
}
#[tokio::test]
#[cfg_attr(not(can_reach_github_com), ignore = "github.com is not reachable")]
async fn can_download_wasmcloud_burrito() {
let download_dir = temp_dir().join("can_download_wasmcloud_burrito");
let res = ensure_wasmcloud_for_os_arch_pair(WASMCLOUD_VERSION, &download_dir)
.await
.expect("Should be able to download tarball");
assert_eq!(
find_wasmcloud_binary(&download_dir, WASMCLOUD_VERSION)
.await
.expect("Should have found installed wasmcloud"),
res
);
let _ = remove_dir_all(download_dir).await;
}
#[tokio::test]
#[cfg_attr(not(can_reach_github_com), ignore = "github.com is not reachable")]
async fn can_handle_missing_wasmcloud_version() {
let download_dir = temp_dir().join("can_handle_missing_wasmcloud_version");
let res = ensure_wasmcloud("v10233.123.3.4", &download_dir).await;
assert!(res.is_err());
let _ = remove_dir_all(download_dir).await;
}
#[tokio::test]
#[cfg_attr(not(can_reach_github_com), ignore = "github.com is not reachable")]
async fn can_download_different_versions() {
let download_dir = temp_dir().join("can_download_different_versions");
ensure_wasmcloud_for_os_arch_pair(WASMCLOUD_VERSION, &download_dir)
.await
.expect("Should be able to download host");
assert!(
find_wasmcloud_binary(&download_dir, WASMCLOUD_VERSION)
.await
.is_some(),
"wasmCloud should be installed"
);
assert!(
download_dir.join(WASMCLOUD_VERSION).exists(),
"Directory should exist"
);
let _ = remove_dir_all(download_dir).await;
}
const NATS_SERVER_VERSION: &str = "v2.10.7";
#[tokio::test]
#[cfg_attr(not(can_reach_github_com), ignore = "github.com is not reachable")]
async fn can_download_and_start_wasmcloud() -> anyhow::Result<()> {
#[cfg(target_family = "unix")]
let install_dir = temp_dir().join("can_download_and_start_wasmcloud");
#[cfg(target_family = "windows")]
let install_dir = std::env::current_dir()?.join("can_download_and_start_wasmcloud");
let _ = remove_dir_all(&install_dir).await;
create_dir_all(&install_dir).await?;
assert!(find_wasmcloud_binary(&install_dir, WASMCLOUD_VERSION)
.await
.is_none());
let nats_port = find_open_port().await?;
assert!(ensure_nats_server(NATS_SERVER_VERSION, &install_dir)
.await
.is_ok());
assert!(is_bin_installed(&install_dir, NATS_SERVER_BINARY).await);
let config = NatsConfig::new_standalone("127.0.0.1", nats_port, None);
let mut nats_child = start_nats_server(
install_dir.join(NATS_SERVER_BINARY),
std::process::Stdio::null(),
config,
)
.await
.expect("Unable to start nats process");
let wasmcloud_binary = ensure_wasmcloud(WASMCLOUD_VERSION, &install_dir)
.await
.expect("Unable to ensure wasmcloud");
let stderr_log_path = wasmcloud_binary
.parent()
.unwrap()
.parent()
.unwrap()
.join("wasmcloud_stderr.log");
let stderr_log_file = tokio::fs::File::create(&stderr_log_path)
.await?
.into_std()
.await;
let stdout_log_path = wasmcloud_binary
.parent()
.unwrap()
.parent()
.unwrap()
.join("wasmcloud_stdout.log");
let stdout_log_file = tokio::fs::File::create(&stdout_log_path)
.await?
.into_std()
.await;
let mut host_env = HashMap::new();
host_env.insert("WASMCLOUD_RPC_PORT".to_string(), nats_port.to_string());
host_env.insert("WASMCLOUD_CTL_PORT".to_string(), nats_port.to_string());
let mut host_child = start_wasmcloud_host(
&wasmcloud_binary,
stdout_log_file,
stderr_log_file,
host_env,
)
.await
.expect("Unable to start wasmcloud host");
println!("waiting for wasmcloud to start..");
let startup_log_path = stderr_log_path.clone();
tokio::time::timeout(Duration::from_secs(10), async move {
loop {
match tokio::fs::read_to_string(&startup_log_path).await {
Ok(file_contents) if !file_contents.is_empty() => break,
_ => {
println!("wasmCloud hasn't started up yet, waiting 1 second");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
})
.await
.context("failed to start wasmcloud (log path is missing)")?;
println!("wasmCloud has started, waiting for expected startup logs...");
let startup_log_path = stderr_log_path.clone();
tokio::time::timeout(Duration::from_secs(15), async move {
loop {
match tokio::fs::read_to_string(&startup_log_path).await {
Ok(file_contents) => {
if file_contents.contains("wasmCloud host started") {
tokio::time::sleep(Duration::from_secs(3)).await;
break;
}
}
_ => {
println!("no host startup logs in output yet, waiting 1 second");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
})
.await
.context("failed to start wasmcloud (logs did not contain expected content)")?;
let mut host_env = HashMap::new();
host_env.insert("WASMCLOUD_RPC_PORT".to_string(), nats_port.to_string());
host_env.insert("WASMCLOUD_CTL_PORT".to_string(), nats_port.to_string());
let child_res = start_wasmcloud_host(
&wasmcloud_binary,
std::process::Stdio::null(),
std::process::Stdio::null(),
host_env,
)
.await;
assert!(child_res.is_ok());
child_res.unwrap().kill().await?;
host_child.kill().await?;
nats_child.kill().await?;
let _ = remove_dir_all(install_dir).await;
Ok(())
}
#[tokio::test]
async fn can_properly_deny_too_old_hosts() -> anyhow::Result<()> {
assert!(check_version("v0.81.0").is_ok());
assert!(check_version(MINIMUM_WASMCLOUD_VERSION).is_ok());
assert!(check_version("v0.81.0-rc1").is_ok());
assert!(check_version("v0.80.99").is_err());
if let Err(e) = check_version("v0.56.0") {
assert_eq!(e.to_string(), format!("wasmCloud version v0.56.0 is earlier than the minimum supported version of v{MINIMUM_WASMCLOUD_VERSION}"));
} else {
panic!("v0.56.0 should be before the minimum version")
}
assert!(check_version("ungabunga").is_ok());
assert!(check_version("v11.1").is_ok());
Ok(())
}
}