wash_lib/
app.rs

1//! Interact with and manage wadm applications over NATS, requires the `nats` feature
2//!
3//! This crate is essentially a wrapper around the wadm_client crate, and it's recommended to use
4//! that crate directly instead.
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7use std::time::Duration;
8
9use anyhow::{bail, Context};
10use async_nats::Client;
11use regex::Regex;
12use tokio::io::{AsyncRead, AsyncReadExt};
13use tracing::warn;
14use url::Url;
15use wadm_client::Result;
16use wadm_types::api::{ModelSummary, Status, VersionInfo};
17use wadm_types::validation::{validate_manifest, ValidationFailure, ValidationFailureLevel};
18use wadm_types::{CapabilityProperties, ComponentProperties, Manifest, Properties};
19use wasmcloud_core::tls;
20use wasmcloud_core::OciFetcher;
21
22use crate::config::DEFAULT_LATTICE;
23
24#[derive(Debug)]
25pub enum AppManifest {
26    SerializedModel(serde_yaml::Value),
27    ModelName(String),
28}
29
30impl AppManifest {
31    /// Resolve relative file paths in the given app manifest to some base path
32    pub fn resolve_image_relative_file_paths(&mut self, base: impl AsRef<Path>) -> Result<()> {
33        if let AppManifest::SerializedModel(ref mut content) = self {
34            resolve_relative_file_paths_in_yaml(content, base)?;
35        }
36        Ok(())
37    }
38
39    /// Retrieve the name of a given [`AppManifest`]
40    pub fn name(&self) -> Option<&str> {
41        match self {
42            AppManifest::ModelName(name) => Some(name),
43            AppManifest::SerializedModel(manifest) => manifest
44                .get("metadata")?
45                .get("name")
46                .and_then(|v| v.as_str()),
47        }
48    }
49
50    /// Retrieve the version of a given [`AppManifest`], returning None if the manifest
51    /// does not contain a version (or is not the type to contain a version)
52    pub fn version(&self) -> Option<&str> {
53        match self {
54            AppManifest::ModelName(_) => None,
55            AppManifest::SerializedModel(manifest) => manifest
56                .get("metadata")?
57                .get("annotations")?
58                .get("version")
59                .and_then(|v| v.as_str()),
60        }
61    }
62}
63
64/// Resolve the relative paths in a YAML value, given a base path (directory)
65/// from which to resolve the relative paths that are found
66fn resolve_relative_file_paths_in_yaml(
67    content: &mut serde_yaml::Value,
68    base_dir: impl AsRef<Path>,
69) -> Result<()> {
70    match content {
71        // If we encounter a string anywhere that is a relative path, resolve it
72        serde_yaml::Value::String(s)
73            if s.starts_with("file://") && s.chars().nth(7).is_some_and(|v| v != '/') =>
74        {
75            // Convert the base dir + relative path into a file based URL
76            let full_path = base_dir.as_ref().join(
77                s.strip_prefix("file://")
78                    .context("failed to strip prefix on relative file path")?,
79            );
80
81            // Ensure the resolved relative path exists
82            if !full_path.exists() {
83                return Err(wadm_client::error::ClientError::ManifestLoad(
84                    anyhow::anyhow!(
85                        "relative file path [{s}] (resolved to [{}]) does not exist",
86                        full_path.display()
87                    ),
88                ));
89            }
90
91            // Build a file based URL and replace the existing one
92            if let Ok(url) = Url::from_file_path(&full_path) {
93                *s = url.into();
94            } else {
95                warn!(
96                    "failed to build a file URL from path [{}], is the file missing?",
97                    full_path.display()
98                );
99            }
100        }
101        // If the YAML value is a mapping, recur into it to process more values
102        serde_yaml::Value::Mapping(m) => {
103            for (_key, value) in m.iter_mut() {
104                resolve_relative_file_paths_in_yaml(value, base_dir.as_ref())?;
105            }
106        }
107        // If the YAML value is a sequence, recur into it to process more values
108        serde_yaml::Value::Sequence(values) => {
109            for value in values {
110                resolve_relative_file_paths_in_yaml(value, base_dir.as_ref())?;
111            }
112        }
113        // All other cases we can ignore replacements
114        _ => {}
115    }
116    Ok(())
117}
118
119pub trait AsyncReadSource: AsyncRead + Unpin + Send + Sync {}
120impl<T: AsyncRead + Unpin + Send + Sync> AsyncReadSource for T {}
121pub enum AppManifestSource {
122    AsyncReadSource(Box<dyn AsyncReadSource>),
123    File(PathBuf),
124    Url(url::Url),
125    // the inner string is intended to be the model name
126    Model(String),
127}
128
129impl FromStr for AppManifestSource {
130    type Err = anyhow::Error;
131
132    fn from_str(s: &str) -> anyhow::Result<Self, Self::Err> {
133        if s == "-" {
134            return Ok(Self::AsyncReadSource(Box::new(tokio::io::stdin())));
135        }
136
137        // Is the source a file path?
138        if PathBuf::from(s).is_file() {
139            match PathBuf::from(s).extension() {
140                    Some(ext) if ext == "yaml" || ext == "yml" || ext == "json" => {
141                        return Ok(Self::File(PathBuf::from(s)));
142                    }
143                    _ => bail!("file {} has an unsupported extension. Only .yaml, .yml, and .json are supported at this time", s),
144
145                }
146        }
147
148        // Is the source a url?
149        if Url::parse(s).is_ok() {
150            if !s.starts_with("http") {
151                bail!("file url {} has an unsupported scheme. Only http(s):// is supported at this time", s)
152            }
153
154            return Ok(Self::Url(url::Url::parse(s)?));
155        }
156
157        // Is the source a valid model name?
158        let model_name_regex =
159            Regex::new(r"^[-\w]+$").context("failed to instantiate manifest name regex")?;
160
161        if model_name_regex.is_match(s) {
162            return Ok(Self::Model(s.to_owned()));
163        }
164
165        bail!("invalid manifest source: {}", s)
166    }
167}
168
169/// Undeploy a model, instructing wadm to no longer manage the given application
170///
171/// # Arguments
172/// * `client` - The [`Client`] to use in order to send the request message
173/// * `lattice` - Optional lattice name that the application is managed on, defaults to `default`
174/// * `model_name` - Model name to undeploy
175pub async fn undeploy_model(
176    client: &Client,
177    lattice: Option<String>,
178    model_name: &str,
179) -> Result<()> {
180    let wadm_client = wadm_client::Client::from_nats_client(
181        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
182        None,
183        client.clone(),
184    );
185
186    wadm_client.undeploy_manifest(model_name).await.map(|_| ())
187}
188
189/// Deploy a model, instructing wadm to manage the application
190///
191/// # Arguments
192/// * `client` - The [`Client`] to use in order to send the request message
193/// * `lattice` - Optional lattice name that the application will be managed on, defaults to `default`
194/// * `model_name` - Model name to deploy
195/// * `version` - Version to deploy, defaults to deploying the latest "put" version
196pub async fn deploy_model(
197    client: &Client,
198    lattice: Option<String>,
199    model_name: &str,
200    version: Option<String>,
201) -> Result<(String, Option<String>)> {
202    let wadm_client = wadm_client::Client::from_nats_client(
203        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
204        None,
205        client.clone(),
206    );
207
208    wadm_client
209        .deploy_manifest(model_name, version.as_deref())
210        .await
211}
212
213/// Put a model definition, instructing wadm to store the application manifest for later deploys
214///
215/// # Arguments
216/// * `client` - The [`Client`] to use in order to send the request message
217/// * `lattice` - Optional lattice name that the application manifest will be stored on, defaults to `default`
218/// * `model` - The full YAML or JSON string containing the OAM wadm manifest
219///
220/// # Returns
221/// The name and version of the model that was put
222pub async fn put_model(
223    client: &Client,
224    lattice: Option<String>,
225    model: &str,
226) -> Result<(String, String)> {
227    let wadm_client = wadm_client::Client::from_nats_client(
228        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
229        None,
230        client.clone(),
231    );
232
233    let manifest = model.as_bytes();
234    wadm_client.put_manifest(manifest).await
235}
236
237/// Deploy a model, instructing wadm to manage the application
238///
239/// # Arguments
240/// * `client` - The [`Client`] to use in order to send the request message
241/// * `lattice` - Optional lattice name that the application will be managed on, defaults to `default`
242/// * `model` - The full YAML or JSON string containing the OAM wadm manifest
243///
244/// # Returns
245/// The name and version of the model that was put
246pub async fn put_and_deploy_model(
247    client: &Client,
248    lattice: Option<String>,
249    model: &str,
250) -> Result<(String, String)> {
251    let wadm_client = wadm_client::Client::from_nats_client(
252        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
253        None,
254        client.clone(),
255    );
256
257    let manifest = model.as_bytes();
258    wadm_client.put_and_deploy_manifest(manifest).await
259}
260
261/// Query wadm for the history of a given model name
262///
263/// # Arguments
264/// * `client` - The [`Client`] to use in order to send the request message
265/// * `lattice` - Optional lattice name that the application manifest is stored on, defaults to `default`
266/// * `model_name` - Name of the model to retrieve history for
267pub async fn get_model_history(
268    client: &Client,
269    lattice: Option<String>,
270    model_name: &str,
271) -> Result<Vec<VersionInfo>> {
272    let wadm_client = wadm_client::Client::from_nats_client(
273        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
274        None,
275        client.clone(),
276    );
277
278    wadm_client.list_versions(model_name).await
279}
280
281/// Query wadm for the status of a given model by name
282///
283/// # Arguments
284/// * `client` - The [`Client`] to use in order to send the request message
285/// * `lattice` - Optional lattice name that the application manifest is stored on, defaults to `default`
286/// * `model_name` - Name of the model to retrieve status for
287pub async fn get_model_status(
288    client: &Client,
289    lattice: Option<String>,
290    model_name: &str,
291) -> Result<Status> {
292    let wadm_client = wadm_client::Client::from_nats_client(
293        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
294        None,
295        client.clone(),
296    );
297
298    wadm_client.get_manifest_status(model_name).await
299}
300
301/// Query wadm for details on a given model
302///
303/// # Arguments
304/// * `client` - The [`Client`] to use in order to send the request message
305/// * `lattice` - Optional lattice name that the application manifest is stored on, defaults to `default`
306/// * `model_name` - Name of the model to retrieve history for
307/// * `version` - Version to retrieve, defaults to retrieving the latest "put" version
308pub async fn get_model_details(
309    client: &Client,
310    lattice: Option<String>,
311    model_name: &str,
312    version: Option<String>,
313) -> Result<Manifest> {
314    let wadm_client = wadm_client::Client::from_nats_client(
315        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
316        None,
317        client.clone(),
318    );
319
320    wadm_client
321        .get_manifest(model_name, version.as_deref())
322        .await
323}
324
325/// Delete a model version from wadm
326///
327/// # Arguments
328/// * `client` - The [`Client`] to use in order to send the request message
329/// * `lattice` - Optional lattice name that the application manifest is stored on, defaults to `default`
330/// * `model_name` - Name of the model
331/// * `version` - Version to retrieve, defaults to deleting the latest "put" version (or all if `delete_all` is specified)
332pub async fn delete_model_version(
333    client: &Client,
334    lattice: Option<String>,
335    model_name: &str,
336    version: Option<String>,
337) -> Result<bool> {
338    let wadm_client = wadm_client::Client::from_nats_client(
339        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
340        None,
341        client.clone(),
342    );
343
344    wadm_client
345        .delete_manifest(model_name, version.as_deref())
346        .await
347}
348
349/// Query wadm for all application manifests
350///
351/// # Arguments
352/// * `client` - The [`Client`] to use in order to send the request message
353/// * `lattice` - Optional lattice name that the application manifests are stored on, defaults to `default`
354pub async fn get_models(client: &Client, lattice: Option<String>) -> Result<Vec<ModelSummary>> {
355    let wadm_client = wadm_client::Client::from_nats_client(
356        &lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
357        None,
358        client.clone(),
359    );
360
361    wadm_client.list_manifests().await
362}
363
364//  NOTE(ahmedtadde): This should probably be refactored at some point to account for cases where the source's input is unusually (or erroneously) large.
365//  For now, we'll just assume that the input is small enough to be a oneshot read into memory and that the default timeout of 1 sec is plenty sufficient (or even too generous?) for the desired/expected behavior.
366pub async fn load_app_manifest(source: AppManifestSource) -> anyhow::Result<AppManifest> {
367    let load_from_source = || async {
368        match source {
369            AppManifestSource::AsyncReadSource(mut stdin) => {
370                let mut buffer = String::new();
371                stdin
372                    .read_to_string(&mut buffer)
373                    .await
374                    .context("failed to read model from stdin")?;
375                if buffer.is_empty() {
376                    bail!("unable to load app manifest from empty stdin input")
377                }
378
379                Ok(AppManifest::SerializedModel(
380                    serde_yaml::from_str(&buffer).context("failed to parse yaml from STDIN")?,
381                ))
382            }
383            AppManifestSource::File(path) => {
384                let mut manifest = AppManifest::SerializedModel(
385                    serde_yaml::from_str(
386                        tokio::fs::read_to_string(&path)
387                            .await
388                            .context("failed to read model from file")?
389                            .as_str(),
390                    )
391                    .with_context(|| {
392                        format!("failed to parse yaml from file @ [{}]", path.display())
393                    })?,
394                );
395
396                // For manifests loaded from a local file, canonicalize the path that held the YAML
397                // and use that directory (immediate parent) to resolve relative file paths inside
398                manifest.resolve_image_relative_file_paths(
399                    path.canonicalize()
400                        .context("failed to canonicalize path to app manifest")?
401                        .parent()
402                        .context("failed to get parent directory of app manifest")?,
403                )?;
404
405                Ok(manifest)
406            }
407            AppManifestSource::Url(url) => {
408                let res = tls::DEFAULT_REQWEST_CLIENT
409                    .get(url.clone())
410                    .send()
411                    .await
412                    .context("request to remote model file failed")?;
413                let text = res
414                    .text()
415                    .await
416                    .context("failed to read model from remote file")?;
417                serde_yaml::from_str(&text)
418                    .with_context(|| format!("failed to parse YAML from URL [{url}]"))
419                    .map(AppManifest::SerializedModel)
420            }
421            AppManifestSource::Model(name) => Ok(AppManifest::ModelName(name)),
422        }
423    };
424
425    // Note(ahmedtadde): considered having a timeout: Option<Duration> parameter, but decided against it since, given the use case for this fn, the callers can fairly
426    // assume that the manifest should be loaded within a reasonable time frame. Now, reasonable is debatable, but i think anything over 1 sec is out of the question as things stand.
427    const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
428    tokio::time::timeout(DEFAULT_TIMEOUT, load_from_source())
429        .await
430        .context("app manifest loader timed out")?
431}
432
433/// Validate the contents of a manifest file and optionally validate the OCI references
434pub async fn validate_manifest_file(
435    manifest_file_path: &Path,
436    oci_check: bool,
437) -> Result<(Manifest, Vec<ValidationFailure>)> {
438    let content = tokio::fs::read_to_string(manifest_file_path)
439        .await
440        .with_context(|| {
441            format!(
442                "failed to read manifest file [{}]",
443                manifest_file_path.display()
444            )
445        })?;
446
447    let manifest = serde_yaml::from_slice(content.as_ref()).with_context(|| {
448        format!(
449            "failed to parse manifest content in file: {}",
450            manifest_file_path.display()
451        )
452    })?;
453
454    let mut failures = validate_manifest(&manifest).await.with_context(|| {
455        format!(
456            "failed to validate manifest in file: {}",
457            manifest_file_path.display()
458        )
459    })?;
460
461    if oci_check {
462        let image_references = extract_image_references(&manifest);
463        validate_oci_references(image_references, &mut failures).await;
464    }
465    Ok((manifest, failures))
466}
467
468pub async fn validate_oci_references(refs: Vec<String>, failures: &mut Vec<ValidationFailure>) {
469    let fetcher = OciFetcher::default();
470
471    for image in refs {
472        if let Err(err) = fetcher.fetch_component(&image).await {
473            let mut fetch_failure = ValidationFailure::default();
474            fetch_failure.level = ValidationFailureLevel::Error;
475            fetch_failure.msg = format!("Failed to fetch OCI component '{}': {}", image, err);
476            failures.push(fetch_failure);
477        }
478    }
479}
480
481/// Extract image references from a given manifest
482pub fn extract_image_references(manifest: &Manifest) -> Vec<String> {
483    let mut image_refs = Vec::new();
484    for component in &manifest.spec.components {
485        match &component.properties {
486            Properties::Component {
487                properties:
488                    ComponentProperties {
489                        image: Some(image), ..
490                    },
491            } => {
492                image_refs.push(image.clone());
493            }
494            Properties::Capability {
495                properties:
496                    CapabilityProperties {
497                        image: Some(image), ..
498                    },
499            } => {
500                image_refs.push(image.clone());
501            }
502            _ => {}
503        }
504    }
505    image_refs
506}
507
508#[cfg(test)]
509mod test {
510    use super::*;
511    use tempfile::tempdir;
512
513    use anyhow::Result;
514
515    #[test]
516    fn test_app_manifest_source_from_str() -> Result<(), Box<dyn std::error::Error>> {
517        // test stdin
518        let stdin = AppManifestSource::from_str("-")?;
519        assert!(
520            matches!(stdin, AppManifestSource::AsyncReadSource(_)),
521            "expected AppManifestSource::AsyncReadSource"
522        );
523
524        // create temporary file for this test
525        let tmp_dir = tempdir()?;
526        std::fs::write(tmp_dir.path().join("foo.yaml"), "foo")?;
527        std::fs::write(tmp_dir.path().join("foo.toml"), "foo")?;
528
529        // test file
530        let file = AppManifestSource::from_str(tmp_dir.path().join("foo.yaml").to_str().unwrap())?;
531        assert!(
532            matches!(file, AppManifestSource::File(_)),
533            "expected AppManifestSource::File"
534        );
535
536        // test url
537        let url = AppManifestSource::from_str(
538            "https://raw.githubusercontent.com/wasmCloud/wasmCloud/main/examples/rust/components/http-hello-world/wadm.yaml",
539        )?;
540
541        assert!(
542            matches!(url, AppManifestSource::Url(_)),
543            "expected AppManifestSource::Url"
544        );
545
546        let url = AppManifestSource::from_str(
547            "https://raw.githubusercontent.com/wasmCloud/wasmCloud/main/examples/rust/components/http-hello-world/wadm.yaml",
548        )?;
549
550        assert!(
551            matches!(url, AppManifestSource::Url(_)),
552            "expected AppManifestSource::Url"
553        );
554
555        // test model
556        let model = AppManifestSource::from_str("foo")?;
557        assert!(
558            matches!(model, AppManifestSource::Model(_)),
559            "expected AppManifestSource::Model"
560        );
561
562        // test invalid
563        let invalid = AppManifestSource::from_str("foo.bar");
564        assert!(
565            invalid.is_err(),
566            "expected error on invalid app manifest model name"
567        );
568
569        let invalid = AppManifestSource::from_str("sftp://foobar.com");
570        assert!(
571            invalid.is_err(),
572            "expected error on invalid app manifest url source"
573        );
574
575        let invalid =
576            AppManifestSource::from_str(tmp_dir.path().join("foo.json").to_str().unwrap());
577
578        assert!(
579            invalid.is_err(),
580            "expected error on invalid app manifest file source"
581        );
582
583        let invalid =
584            AppManifestSource::from_str(tmp_dir.path().join("foo.toml").to_str().unwrap());
585
586        assert!(
587            invalid.is_err(),
588            "expected error on invalid app manifest file source"
589        );
590
591        Ok(())
592    }
593
594    #[tokio::test]
595    async fn test_resolve_relative_manifest() -> Result<()> {
596        let tmp_dir = tempdir()?;
597        std::fs::write(tmp_dir.path().join("foo.yaml"), "exists")?;
598        let mut yaml = serde_yaml::from_str(
599            r#"
600mapping:
601  path: 'file://foo.yaml'
602"#,
603        )
604        .context("failed to build YAML")?;
605
606        resolve_relative_file_paths_in_yaml(&mut yaml, tmp_dir)
607            .context("failed to resolve relative file path")?;
608        assert!(matches!(
609                &yaml["mapping"]["path"],
610                serde_yaml::Value::String(s) if s.contains("file:///") && s.contains("/foo.yaml")
611        ));
612        Ok(())
613    }
614}