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}