wash_lib/
common.rs

1use std::fmt::{Debug, Display};
2use std::path::Path;
3use std::process::Stdio;
4use std::str::FromStr;
5
6use anyhow::{anyhow, bail, Result};
7use tokio::process::Command;
8
9use anyhow::Context;
10use tracing::error;
11use wasmcloud_control_interface::HostInventory;
12
13use crate::id::{ModuleId, ServerId, ServiceId};
14
15/// Default path to the `git` command (assumes it exists on PATH)
16const DEFAULT_GIT_PATH: &str = "git";
17
18const CLAIMS_CALL_ALIAS: &str = "call_alias";
19pub(crate) const CLAIMS_NAME: &str = "name";
20pub(crate) const CLAIMS_SUBJECT: &str = "sub";
21
22/// Converts error from Send + Sync error to standard anyhow error
23pub(crate) fn boxed_err_to_anyhow(e: Box<dyn ::std::error::Error + Send + Sync>) -> anyhow::Error {
24    anyhow::anyhow!(e)
25}
26
27#[derive(Debug, thiserror::Error)]
28pub enum FindIdError {
29    /// No matches were found
30    #[error("No matches found with the provided search term")]
31    NoMatches,
32    /// Multiple matches were found. The vector contains the list of components or providers that
33    /// matched
34    #[error("Multiple matches found with the provided search term: {0:?}")]
35    MultipleMatches(Vec<Match>),
36    #[error(transparent)]
37    Error(#[from] anyhow::Error),
38}
39
40/// Represents a single match against a search term
41#[derive(Clone)]
42pub struct Match {
43    pub id: String,
44    pub friendly_name: Option<String>,
45}
46
47impl Display for Match {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        if let Some(friendly_name) = &self.friendly_name {
50            write!(f, "{} ({friendly_name})", self.id)
51        } else {
52            write!(f, "{}", self.id)
53        }
54    }
55}
56
57impl Debug for Match {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        Display::fmt(&self, f)
60    }
61}
62
63/// Whether or not to use a command group to manage unix/windows signal delivery
64#[derive(Default, Debug, Clone, PartialEq, Eq)]
65pub enum CommandGroupUsage {
66    /// Use the parent command group
67    #[default]
68    UseParent,
69    /// Create a new command group (using this option prevents signals from being delivered)
70    /// automatically to subprocesses
71    CreateNew,
72}
73
74/// Given a string, attempts to resolve a component ID. Returning the component ID and an optional friendly
75/// name
76///
77/// If the string is a valid component ID, it will be returned unchanged. If it is not an ID, it will
78/// attempt to resolve an ID in the following order:
79///
80/// 1. The value matches the prefix of the ID of a component
81/// 2. The value is contained in the call alias of a component
82/// 3. The value is contained in the name field of a component
83///
84/// If more than one matches, then an error will be returned indicating the options to choose from
85pub async fn find_component_id(
86    value: &str,
87    ctl_client: &wasmcloud_control_interface::Client,
88) -> Result<(ModuleId, Option<String>), FindIdError> {
89    find_id_matches(value, ctl_client).await
90}
91
92/// Given a string, attempts to resolve a provider ID. Returning the provider ID and an optional
93/// friendly name
94///
95/// If the string is a valid provider ID, it will be returned unchanged. If it is not an ID, it will
96/// attempt to resolve an ID in the following order:
97///
98/// 1. The value matches the prefix of the ID of a provider
99/// 2. The value is contained in the name field of a provider
100///
101/// If more than one matches, then an error will be returned indicating the options to choose from
102pub async fn find_provider_id(
103    value: &str,
104    ctl_client: &wasmcloud_control_interface::Client,
105) -> Result<(ServiceId, Option<String>), FindIdError> {
106    find_id_matches(value, ctl_client).await
107}
108
109async fn find_id_matches<T: FromStr + ToString + Display>(
110    value: &str,
111    ctl_client: &wasmcloud_control_interface::Client,
112) -> Result<(T, Option<String>), FindIdError> {
113    if let Ok(id) = T::from_str(value) {
114        return Ok((id, None));
115    }
116    // Case insensitive searching here to make things nicer
117    let value = value.to_lowercase();
118    // If it wasn't an ID, get the claims
119    let ctl_response = ctl_client
120        .get_claims()
121        .await
122        .map_err(boxed_err_to_anyhow)
123        .context("unable to get claims for lookup")?;
124    let Some(claims) = ctl_response.into_data() else {
125        error!("received claims response from control interface but no claims were present in the response");
126        return Err(FindIdError::NoMatches);
127    };
128
129    let all_matches = claims
130        .iter()
131        .filter_map(|v| {
132            let id_str = v
133                .get(CLAIMS_SUBJECT)
134                .map(String::as_str)
135                .unwrap_or_default();
136            // If it doesn't parse to our type, just skip
137            let id = match T::from_str(id_str) {
138                Ok(id) => id,
139                Err(_) => return None,
140            };
141            (id_str.to_lowercase().starts_with(&value)
142                || v.get(CLAIMS_CALL_ALIAS)
143                    .map(|s| s.to_lowercase())
144                    .unwrap_or_default()
145                    .contains(&value)
146                || v.get(CLAIMS_NAME)
147                    .map(|s| s.to_ascii_lowercase())
148                    .unwrap_or_default()
149                    .contains(&value))
150            .then(|| (id, v.get(CLAIMS_NAME).map(ToString::to_string)))
151        })
152        .collect::<Vec<_>>();
153
154    if all_matches.is_empty() {
155        Err(FindIdError::NoMatches)
156    } else if all_matches.len() > 1 {
157        Err(FindIdError::MultipleMatches(
158            all_matches
159                .into_iter()
160                .map(|(id, friendly_name)| Match {
161                    id: id.to_string(),
162                    friendly_name,
163                })
164                .collect(),
165        ))
166    } else {
167        // SAFETY: We know we have exactly one match at this point
168        Ok(all_matches.into_iter().next().unwrap())
169    }
170}
171
172/// Given a string, attempts to resolve a host ID. Returning the host ID and its friendly name.
173///
174/// If the string is a valid host ID, it will be returned unchanged. If it is not an ID, it will
175/// attempt to resolve an ID in the following order:
176///
177/// 1. The value matches the prefix of the ID of a host
178/// 2. The value is contained in the friendly name field of a host
179///
180/// If more than one matches, then an error will be returned indicating the options to choose from
181pub async fn find_host_id(
182    value: &str,
183    ctl_client: &wasmcloud_control_interface::Client,
184) -> Result<(ServerId, String), FindIdError> {
185    if let Ok(id) = ServerId::from_str(value) {
186        return Ok((id, String::new()));
187    }
188
189    // Case insensitive searching here to make things nicer
190    let value = value.to_lowercase();
191
192    let hosts = ctl_client
193        .get_hosts()
194        .await
195        .map_err(boxed_err_to_anyhow)
196        .context("unable to fetch hosts for lookup")?;
197
198    let all_matches = hosts
199        .into_iter()
200        .filter_map(|h| h.into_data())
201        .filter_map(|h| {
202            if h.id().to_lowercase().starts_with(&value)
203                || h.friendly_name().to_lowercase().contains(&value)
204            {
205                ServerId::from_str(h.id())
206                    .ok()
207                    .map(|id| (id, h.friendly_name().to_string()))
208            } else {
209                None
210            }
211        })
212        .collect::<Vec<_>>();
213
214    if all_matches.is_empty() {
215        Err(FindIdError::NoMatches)
216    } else if all_matches.len() > 1 {
217        Err(FindIdError::MultipleMatches(
218            all_matches
219                .into_iter()
220                .map(|(id, friendly_name)| Match {
221                    id: id.to_string(),
222                    friendly_name: Some(friendly_name.to_string()),
223                })
224                .collect(),
225        ))
226    } else {
227        // SAFETY: We know we have exactly one match at this point
228        Ok(all_matches.into_iter().next().unwrap())
229    }
230}
231
232pub async fn get_all_inventories(
233    client: &wasmcloud_control_interface::Client,
234) -> anyhow::Result<Vec<HostInventory>> {
235    let hosts = client.get_hosts().await.map_err(boxed_err_to_anyhow)?;
236    let host_ids = match hosts.len() {
237        0 => return Ok(Vec::with_capacity(0)),
238        _ => hosts
239            .into_iter()
240            .filter_map(|h| h.into_data().map(|h| h.id().to_string())),
241    };
242
243    let futs =
244        host_ids
245            .map(|host_id| (client.clone(), host_id))
246            .map(|(client, host_id)| async move {
247                client
248                    .get_host_inventory(&host_id)
249                    .await
250                    .map(|inventory| inventory.into_data())
251                    .map_err(boxed_err_to_anyhow)
252            });
253    futures::future::join_all(futs)
254        .await
255        .into_iter()
256        .filter_map(Result::transpose)
257        .collect::<anyhow::Result<Vec<HostInventory>>>()
258}
259
260/// Reference that can be used on a cloned Git repo
261#[derive(Debug, Eq, PartialEq)]
262pub enum RepoRef {
263    /// When a reference is unknown/unspecified
264    Unknown(String),
265    /// A git branch (ex. 'main')
266    Branch(String),
267    /// A git tag (ex. 'v0.1.0')
268    Tag(String),
269    /// A git SHA, possibly with the (ex. 'sha256:abcdefgh...', 'abcdefgh...')
270    Sha(String),
271}
272
273impl RepoRef {
274    /// Retrieve the git ref for this repo ref
275    #[must_use]
276    pub fn git_ref(&self) -> &str {
277        match self {
278            RepoRef::Unknown(s) => s,
279            RepoRef::Branch(s) => s,
280            RepoRef::Tag(s) => s,
281            RepoRef::Sha(s) if s.starts_with("sha:") => &s[4..],
282            RepoRef::Sha(s) => s,
283        }
284    }
285}
286
287impl FromStr for RepoRef {
288    type Err = anyhow::Error;
289
290    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
291        Ok(match s.strip_prefix("sha:") {
292            Some(s) => Self::Sha(s.into()),
293            None => Self::Unknown(s.into()),
294        })
295    }
296}
297
298/// Clone a git repository
299pub async fn clone_git_repo(
300    git_cmd: Option<String>,
301    tmp_dir: impl AsRef<Path>,
302    repo_url: String,
303    sub_folder: Option<String>,
304    repo_ref: Option<RepoRef>,
305) -> Result<()> {
306    let git_cmd = git_cmd.unwrap_or_else(|| DEFAULT_GIT_PATH.into());
307    let tmp_dir = tmp_dir.as_ref();
308    let cwd =
309        std::env::current_dir().map_err(|e| anyhow!("could not get current directory: {}", e))?;
310    std::env::set_current_dir(tmp_dir)
311        .map_err(|e| anyhow!("could not cd to tmp dir {}: {}", tmp_dir.display(), e))?;
312
313    // For convenience, allow omission of prefix 'https://' or 'https://github.com'
314    let repo_url = {
315        if repo_url.starts_with("http://") || repo_url.starts_with("https://") {
316            repo_url
317        } else if repo_url.starts_with("git+https://")
318            || repo_url.starts_with("git+http://")
319            || repo_url.starts_with("git+ssh")
320        {
321            repo_url.replace("git+", "")
322        } else if repo_url.starts_with("github.com/") {
323            format!("https://{}", &repo_url)
324        } else {
325            format!("https://github.com/{}", repo_url.trim_start_matches('/'))
326        }
327    };
328
329    // Ensure the repo URL does not have any query parameters
330    let repo_url = {
331        let mut url = reqwest::Url::parse(&repo_url)?;
332        url.query_pairs_mut().clear();
333        format!(
334            "{}://{}{}",
335            match url.scheme() {
336                "ssh" => "ssh",
337                _ => "https",
338            },
339            url.authority(),
340            url.path()
341        )
342    };
343
344    // Build args for git clone command
345    let mut args = vec!["clone", &repo_url, "--no-checkout", "."];
346    // Only perform a shallow clone if we're dealing with a branch or tag checkout
347    // All other forms *may* need to access arbitrarily old commits
348    if let Some(RepoRef::Branch(_) | RepoRef::Tag(_)) = repo_ref {
349        args.push("--depth");
350        args.push("1");
351    }
352
353    // If the ref was provided and a branch, we can clone that branch directly
354    if let Some(RepoRef::Branch(ref branch)) = repo_ref {
355        args.push("--branch");
356        args.push(branch);
357    }
358
359    let clone_cmd_out = Command::new(&git_cmd)
360        .args(args)
361        .stdin(Stdio::piped())
362        .stdout(Stdio::piped())
363        .stderr(Stdio::piped())
364        .spawn()?
365        .wait_with_output()
366        .await?;
367    if !clone_cmd_out.status.success() {
368        bail!(
369            "git clone error: {}",
370            String::from_utf8_lossy(&clone_cmd_out.stderr)
371        );
372    }
373
374    // If we are pulling a non-branch ref, we need to perform an actual
375    // checkout of the ref (branches use the --branch switch during checkout)
376    if let Some(repo_ref) = repo_ref {
377        let checkout_cmd_out = Command::new(&git_cmd)
378            .args(["checkout", repo_ref.git_ref()])
379            .stdin(Stdio::piped())
380            .stdout(Stdio::piped())
381            .stderr(Stdio::piped())
382            .spawn()?
383            .wait_with_output()
384            .await?;
385        if !checkout_cmd_out.status.success() {
386            bail!(
387                "git checkout error: {}",
388                String::from_utf8_lossy(&checkout_cmd_out.stderr)
389            );
390        }
391    }
392
393    // After we've pulled the right ref, we can descend into a subfolder if specified
394    if let Some(sub_folder) = sub_folder {
395        let checkout_cmd_out = Command::new(&git_cmd)
396            .args(["sparse-checkout", "set", &sub_folder])
397            .stdin(Stdio::piped())
398            .stdout(Stdio::piped())
399            .stderr(Stdio::piped())
400            .spawn()?
401            .wait_with_output()
402            .await?;
403        if !checkout_cmd_out.status.success() {
404            bail!(
405                "git sparse-checkout set error: {}",
406                String::from_utf8_lossy(&checkout_cmd_out.stderr)
407            );
408        }
409    }
410
411    std::env::set_current_dir(cwd)?;
412    Ok(())
413}