1use 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 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 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 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
64fn resolve_relative_file_paths_in_yaml(
67 content: &mut serde_yaml::Value,
68 base_dir: impl AsRef<Path>,
69) -> Result<()> {
70 match content {
71 serde_yaml::Value::String(s)
73 if s.starts_with("file://") && s.chars().nth(7).is_some_and(|v| v != '/') =>
74 {
75 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 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 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 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 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 _ => {}
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 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 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 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 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
169pub 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
189pub 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
213pub 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
237pub 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
261pub 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
281pub 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
301pub 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
325pub 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
349pub 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
364pub 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 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 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
433pub 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
481pub 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 let stdin = AppManifestSource::from_str("-")?;
519 assert!(
520 matches!(stdin, AppManifestSource::AsyncReadSource(_)),
521 "expected AppManifestSource::AsyncReadSource"
522 );
523
524 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 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 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 let model = AppManifestSource::from_str("foo")?;
557 assert!(
558 matches!(model, AppManifestSource::Model(_)),
559 "expected AppManifestSource::Model"
560 );
561
562 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}