wash_lib/start/
wasmcloud.rs

1use std::collections::HashMap;
2
3use anyhow::{bail, Result};
4use reqwest::StatusCode;
5use tokio::fs::{create_dir_all, metadata, File};
6use tokio::process::{Child, Command};
7use tokio_stream::StreamExt;
8use tokio_util::io::StreamReader;
9use tracing::warn;
10
11#[cfg(target_family = "unix")]
12use std::os::unix::prelude::PermissionsExt;
13use std::path::{Path, PathBuf};
14use std::process::Stdio;
15
16#[cfg(target_family = "unix")]
17use command_group::AsyncCommandGroup;
18
19use super::get_download_client;
20
21const WASMCLOUD_GITHUB_RELEASE_URL: &str =
22    "https://github.com/wasmCloud/wasmCloud/releases/download";
23#[cfg(target_family = "unix")]
24pub const WASMCLOUD_HOST_BIN: &str = "wasmcloud_host";
25#[cfg(target_family = "windows")]
26pub const WASMCLOUD_HOST_BIN: &str = "wasmcloud_host.exe";
27
28// Any version of wasmCloud under 0.81 does not support wasmtime 16 wit worlds and is incompatible.
29const MINIMUM_WASMCLOUD_VERSION: &str = "0.81.0";
30
31/// A wrapper around the [`ensure_wasmcloud_for_os_arch_pair`] function that uses the
32/// architecture and operating system of the current host machine.
33///
34/// # Arguments
35///
36/// * `version` - Specifies the version of the binary to download in the form of `vX.Y.Z`. Must be at least v0.63.0.
37/// * `dir` - Where to unpack the wasmCloud host contents into
38/// # Examples
39///
40/// ```no_run
41/// # #[tokio::main]
42/// # async fn main() {
43/// use wash_lib::start::ensure_wasmcloud;
44/// let res = ensure_wasmcloud("v0.63.0", "/tmp/wasmcloud/").await;
45/// assert!(res.is_ok());
46/// assert!(res.unwrap().to_string_lossy() == "/tmp/wasmcloud/v0.63.0/wasmcloud_host".to_string());
47/// # }
48/// ```
49pub async fn ensure_wasmcloud<P>(version: &str, dir: P) -> Result<PathBuf>
50where
51    P: AsRef<Path>,
52{
53    ensure_wasmcloud_for_os_arch_pair(version, dir).await
54}
55
56/// Ensures the `wasmcloud_host` application is installed, returning the path to the executable
57/// early if it exists or downloading the specified GitHub release version of the wasmCloud host
58/// from <https://github.com/wasmCloud/wasmcloud-otp/releases/> and unpacking the contents for a
59/// specified OS/ARCH pair to a directory. Returns the path to the executable.
60///
61/// # Arguments
62///
63/// * `os` - Specifies the operating system of the binary to download, e.g. `linux`
64/// * `arch` - Specifies the architecture of the binary to download, e.g. `amd64`
65/// * `version` - Specifies the version of the binary to download in the form of `vX.Y.Z`. Must be
66///   at least v0.63.0.
67/// * `dir` - Where to unpack the wasmCloud host contents into. This should be the root level
68///   directory where to store hosts. Each host will be stored in a directory matching its version
69///   (e.g. "/tmp/wasmcloud/v0.63.0")
70/// # Examples
71///
72/// ```no_run
73/// # #[tokio::main]
74/// # async fn main() {
75/// use wash_lib::start::ensure_wasmcloud_for_os_arch_pair;
76/// let os = std::env::consts::OS;
77/// let arch = std::env::consts::ARCH;
78/// let res = ensure_wasmcloud_for_os_arch_pair("v0.63.0", "/tmp/wasmcloud/").await;
79/// assert!(res.is_ok());
80/// assert!(res.unwrap().to_string_lossy() == "/tmp/wasmcloud/v0.63.0/wasmcloud_host".to_string());
81/// # }
82/// ```
83pub async fn ensure_wasmcloud_for_os_arch_pair<P>(version: &str, dir: P) -> Result<PathBuf>
84where
85    P: AsRef<Path>,
86{
87    check_version(version)?;
88    if let Some(dir) = find_wasmcloud_binary(&dir, version).await {
89        // wasmCloud already exists, return early
90        return Ok(dir);
91    }
92    // Download wasmCloud host tarball
93    download_wasmcloud_for_os_arch_pair(version, dir).await
94}
95
96/// A wrapper around the [`download_wasmcloud_for_os_arch_pair`] function that uses the
97/// architecture and operating system of the current host machine.
98///
99/// # Arguments
100///
101/// * `version` - Specifies the version of the binary to download in the form of `vX.Y.Z`
102/// * `dir` - Where to unpack the wasmCloud host contents into. This should be the root level
103///   directory where to store hosts. Each host will be stored in a directory matching its version
104/// # Examples
105///
106/// ```no_run
107/// # #[tokio::main]
108/// # async fn main() {
109/// use wash_lib::start::download_wasmcloud;
110/// let res = download_wasmcloud("v0.57.1", "/tmp/wasmcloud/").await;
111/// assert!(res.is_ok());
112/// assert!(res.unwrap().to_string_lossy() == "/tmp/wasmcloud/v0.63.0/wasmcloud_host".to_string());
113/// # }
114/// ```
115pub async fn download_wasmcloud<P>(version: &str, dir: P) -> Result<PathBuf>
116where
117    P: AsRef<Path>,
118{
119    download_wasmcloud_for_os_arch_pair(version, dir).await
120}
121
122/// Downloads the specified GitHub release version of the wasmCloud host from
123/// <https://github.com/wasmCloud/wasmcloud-otp/releases/> and unpacking the contents for a
124/// specified OS/ARCH pair to a directory. Returns the path to the Elixir executable.
125///
126/// # Arguments
127///
128/// * `os` - Specifies the operating system of the binary to download, e.g. `linux`
129/// * `arch` - Specifies the architecture of the binary to download, e.g. `amd64`
130/// * `version` - Specifies the version of the binary to download in the form of `vX.Y.Z`
131/// * `dir` - Where to unpack the wasmCloud host contents into. This should be the root level
132///   directory where to store hosts. Each host will be stored in a directory matching its version
133/// # Examples
134///
135/// ```rust,ignore
136/// # #[tokio::main]
137/// # async fn main() {
138/// use wash_lib::start::download_wasmcloud_for_os_arch_pair;
139/// let os = std::env::consts::OS;
140/// let arch = std::env::consts::ARCH;
141/// let res = download_wasmcloud_for_os_arch_pair("v0.63.0", "/tmp/wasmcloud/").await;
142/// assert!(res.is_ok());
143/// assert!(res.unwrap().to_string_lossy() == "/tmp/wasmcloud/v0.63.0/wasmcloud_host".to_string());
144/// # }
145/// ```
146pub async fn download_wasmcloud_for_os_arch_pair<P>(version: &str, dir: P) -> Result<PathBuf>
147where
148    P: AsRef<Path>,
149{
150    let url = wasmcloud_url(version);
151    // NOTE(brooksmtownsend): This seems like a lot of work when I really just want to use AsyncRead
152    // to pipe the response body into a file. I'm not sure if there's a better way to do this.
153    let download_response = get_download_client()?.get(&url).send().await?;
154    if download_response.status() != StatusCode::OK {
155        bail!(
156            "failed to download wasmCloud host from {}. Status code: {}",
157            url,
158            download_response.status()
159        );
160    }
161
162    let burrito_bites_stream = download_response
163        .bytes_stream()
164        .map(|result| result.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
165    let mut wasmcloud_host_burrito = StreamReader::new(burrito_bites_stream);
166    let version_dir = dir.as_ref().join(version);
167    let file_path = version_dir.join(WASMCLOUD_HOST_BIN);
168    if let Some(parent_folder) = file_path.parent() {
169        // If the user doesn't have permission to create files in the provided directory,
170        // this will bubble the error up noting permission denied
171        create_dir_all(parent_folder).await?;
172    }
173    if let Ok(mut wasmcloud_file) = File::create(&file_path).await {
174        // This isn't an `if let` to avoid a Windows lint warning
175        if file_path.file_name().is_some() {
176            // Set permissions of executable files and binaries to allow executing
177            #[cfg(target_family = "unix")]
178            {
179                let mut perms = wasmcloud_file.metadata().await?.permissions();
180                perms.set_mode(0o755);
181                wasmcloud_file.set_permissions(perms).await?;
182            }
183        }
184        tokio::io::copy(&mut wasmcloud_host_burrito, &mut wasmcloud_file).await?;
185    }
186
187    // Return success if wasmCloud components exist, error otherwise
188    match find_wasmcloud_binary(&dir, version).await {
189        Some(path) => Ok(path),
190        None => bail!("wasmCloud was not installed successfully, please see logs"),
191    }
192}
193
194/// Helper function to start a wasmCloud host given the path to the burrito release application
195/// # Arguments
196///
197/// * `bin_path` - Path to the `wasmcloud_host` burrito application
198/// * `stdout` - Specify where wasmCloud stdout logs should be written to. Logs can be written to stdout by the erlang process
199/// * `stderr` - Specify where wasmCloud stderr logs should be written to. Logs are written to stderr that are generated by wasmCloud
200/// * `env_vars` - Environment variables to pass to the host, see <https://wasmcloud.dev/reference/host-runtime/host_configure/#supported-configuration-variables> for details
201pub async fn start_wasmcloud_host<P, T, S>(
202    bin_path: P,
203    stdout: T,
204    stderr: S,
205    env_vars: HashMap<String, String>,
206) -> Result<Child>
207where
208    P: AsRef<Path>,
209    T: Into<Stdio>,
210    S: Into<Stdio>,
211{
212    // Constructing this object in one step results in a temporary value that's dropped
213    let mut cmd = Command::new(bin_path.as_ref());
214    let cmd = cmd
215        // wasmCloud host logs are sent to stderr as of https://github.com/wasmCloud/wasmcloud-otp/pull/418
216        .stderr(stderr)
217        .stdout(stdout)
218        // NOTE: while normally we might want to kill_on_drop here, the tests that use this function
219        // manually manage the process that is spawned (see can_download_and_start_wasmcloud)
220        .stdin(Stdio::null())
221        .envs(&env_vars);
222
223    #[cfg(target_family = "unix")]
224    {
225        Ok(cmd.group_spawn()?.into_inner())
226    }
227    #[cfg(target_family = "windows")]
228    {
229        Ok(cmd.spawn()?)
230    }
231}
232
233/// Helper function to indicate if the wasmCloud host tarball is successfully
234/// installed in a directory. Returns the path to the binary if it exists
235pub async fn find_wasmcloud_binary<P>(dir: P, version: &str) -> Option<PathBuf>
236where
237    P: AsRef<Path>,
238{
239    let versioned_dir = dir.as_ref().join(version);
240    let bin_file = versioned_dir.join(WASMCLOUD_HOST_BIN);
241
242    metadata(&bin_file).await.is_ok().then_some(bin_file)
243}
244
245/// Helper function to determine the wasmCloud host release path given an os/arch and version
246fn wasmcloud_url(version: &str) -> String {
247    #[cfg(target_os = "android")]
248    let os = "linux-android";
249
250    #[cfg(target_os = "macos")]
251    let os = "apple-darwin";
252
253    #[cfg(all(target_os = "linux", not(target_arch = "riscv64")))]
254    let os = "unknown-linux-musl";
255
256    #[cfg(all(target_os = "linux", target_arch = "riscv64"))]
257    let os = "unknown-linux-gnu";
258
259    #[cfg(target_os = "windows")]
260    let os = "pc-windows-msvc.exe";
261    format!(
262        "{WASMCLOUD_GITHUB_RELEASE_URL}/{version}/wasmcloud-{arch}-{os}",
263        arch = std::env::consts::ARCH
264    )
265}
266
267/// Helper function to ensure the version of wasmCloud is above the minimum
268/// supported version (v0.63.0) that runs burrito releases
269fn check_version(version: &str) -> Result<()> {
270    let version_req = semver::VersionReq::parse(&format!(">={MINIMUM_WASMCLOUD_VERSION}"))?;
271    match semver::Version::parse(version.trim_start_matches('v')) {
272        Ok(parsed_version) if !parsed_version.pre.is_empty() => {
273            warn!("Using prerelease version {} of wasmCloud", version);
274            Ok(())
275        }
276        Ok(parsed_version) if !version_req.matches(&parsed_version) => bail!(
277            "wasmCloud version {} is earlier than the minimum supported version of v{}",
278            version,
279            MINIMUM_WASMCLOUD_VERSION
280        ),
281        Ok(_ver) => Ok(()),
282        Err(_parse_err) => {
283            warn!("Failed to parse wasmCloud version as a semantic version, download may fail");
284            Ok(())
285        }
286    }
287}
288
289#[cfg(test)]
290mod test {
291    use super::{check_version, MINIMUM_WASMCLOUD_VERSION};
292
293    #[tokio::test]
294    async fn can_properly_deny_too_old_hosts() -> anyhow::Result<()> {
295        // Ensure we allow versions >= 0.81.0
296        assert!(check_version("v0.81.0").is_ok());
297        assert!(check_version(MINIMUM_WASMCLOUD_VERSION).is_ok());
298
299        // Ensure we allow prerelease tags for testing
300        assert!(check_version("v0.81.0-rc1").is_ok());
301
302        // Ensure we deny versions < MINIMUM_WASMCLOUD_VERSION
303        assert!(check_version("v0.80.99").is_err());
304
305        if let Err(e) = check_version("v0.56.0") {
306            assert_eq!(e.to_string(), format!("wasmCloud version v0.56.0 is earlier than the minimum supported version of v{MINIMUM_WASMCLOUD_VERSION}"));
307        } else {
308            panic!("v0.56.0 should be before the minimum version")
309        }
310
311        // The check_version will allow bad semantic versions, rather than failing immediately
312        assert!(check_version("ungabunga").is_ok());
313        assert!(check_version("v11.1").is_ok());
314
315        Ok(())
316    }
317}