use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{bail, Context};
use async_nats::Client;
use regex::Regex;
use tracing::warn;
use wadm_client::Result;
use wadm_types::api::{ModelSummary, Status, VersionInfo};
use tokio::io::{AsyncRead, AsyncReadExt};
use url::Url;
use wadm_types::Manifest;
use wasmcloud_core::tls;
use crate::config::DEFAULT_LATTICE;
#[derive(Debug)]
pub enum AppManifest {
SerializedModel(serde_yaml::Value),
ModelName(String),
}
impl AppManifest {
pub fn resolve_image_relative_file_paths(&mut self, base: impl AsRef<Path>) -> Result<()> {
if let AppManifest::SerializedModel(ref mut content) = self {
resolve_relative_file_paths_in_yaml(content, base)?;
}
Ok(())
}
pub fn name(&self) -> Option<&str> {
match self {
AppManifest::ModelName(name) => Some(name),
AppManifest::SerializedModel(manifest) => manifest
.get("metadata")?
.get("name")
.and_then(|v| v.as_str()),
}
}
pub fn version(&self) -> Option<&str> {
match self {
AppManifest::ModelName(_) => None,
AppManifest::SerializedModel(manifest) => manifest
.get("metadata")?
.get("annotations")?
.get("version")
.and_then(|v| v.as_str()),
}
}
}
fn resolve_relative_file_paths_in_yaml(
content: &mut serde_yaml::Value,
base_dir: impl AsRef<Path>,
) -> Result<()> {
match content {
serde_yaml::Value::String(s)
if s.starts_with("file://") && s.chars().nth(7).is_some_and(|v| v != '/') =>
{
let full_path = base_dir.as_ref().join(
s.strip_prefix("file://")
.context("failed to strip prefix")?,
);
if let Ok(url) = Url::from_file_path(&full_path) {
*s = url.into();
} else {
warn!(
"failed to build a file URL from path [{}], is the file missing?",
full_path.display()
);
}
}
serde_yaml::Value::Mapping(m) => {
for (_key, value) in m.iter_mut() {
resolve_relative_file_paths_in_yaml(value, base_dir.as_ref())?;
}
}
serde_yaml::Value::Sequence(values) => {
for value in values {
resolve_relative_file_paths_in_yaml(value, base_dir.as_ref())?;
}
}
_ => {}
}
Ok(())
}
pub trait AsyncReadSource: AsyncRead + Unpin + Send + Sync {}
impl<T: AsyncRead + Unpin + Send + Sync> AsyncReadSource for T {}
pub enum AppManifestSource {
AsyncReadSource(Box<dyn AsyncReadSource>),
File(PathBuf),
Url(url::Url),
Model(String),
}
impl FromStr for AppManifestSource {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self, Self::Err> {
if s == "-" {
return Ok(Self::AsyncReadSource(Box::new(tokio::io::stdin())));
}
if PathBuf::from(s).is_file() {
match PathBuf::from(s).extension() {
Some(ext) if ext == "yaml" || ext == "yml" || ext == "json" => {
return Ok(Self::File(PathBuf::from(s)));
}
_ => bail!("file {} has an unsupported extension. Only .yaml, .yml, and .json are supported at this time", s),
}
}
if Url::parse(s).is_ok() {
if !s.starts_with("http") {
bail!("file url {} has an unsupported scheme. Only http(s):// is supported at this time", s)
}
return Ok(Self::Url(url::Url::parse(s)?));
}
let model_name_regex =
Regex::new(r"^[-\w]+$").context("failed to instantiate manifest name regex")?;
if model_name_regex.is_match(s) {
return Ok(Self::Model(s.to_owned()));
}
bail!("invalid manifest source: {}", s)
}
}
pub async fn undeploy_model(
client: &Client,
lattice: Option<String>,
model_name: &str,
) -> Result<()> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client.undeploy_manifest(model_name).await.map(|_| ())
}
pub async fn deploy_model(
client: &Client,
lattice: Option<String>,
model_name: &str,
version: Option<String>,
) -> Result<(String, Option<String>)> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client
.deploy_manifest(model_name, version.as_deref())
.await
}
pub async fn put_model(
client: &Client,
lattice: Option<String>,
model: &str,
) -> Result<(String, String)> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
let manifest = model.as_bytes();
wadm_client.put_manifest(manifest).await
}
pub async fn put_and_deploy_model(
client: &Client,
lattice: Option<String>,
model: &str,
) -> Result<(String, String)> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
let manifest = model.as_bytes();
wadm_client.put_and_deploy_manifest(manifest).await
}
pub async fn get_model_history(
client: &Client,
lattice: Option<String>,
model_name: &str,
) -> Result<Vec<VersionInfo>> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client.list_versions(model_name).await
}
pub async fn get_model_status(
client: &Client,
lattice: Option<String>,
model_name: &str,
) -> Result<Status> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client.get_manifest_status(model_name).await
}
pub async fn get_model_details(
client: &Client,
lattice: Option<String>,
model_name: &str,
version: Option<String>,
) -> Result<Manifest> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client
.get_manifest(model_name, version.as_deref())
.await
}
pub async fn delete_model_version(
client: &Client,
lattice: Option<String>,
model_name: &str,
version: Option<String>,
) -> Result<bool> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client
.delete_manifest(model_name, version.as_deref())
.await
}
pub async fn get_models(client: &Client, lattice: Option<String>) -> Result<Vec<ModelSummary>> {
let wadm_client = wadm_client::Client::from_nats_client(
&lattice.unwrap_or_else(|| DEFAULT_LATTICE.to_string()),
None,
client.clone(),
);
wadm_client.list_manifests().await
}
pub async fn load_app_manifest(source: AppManifestSource) -> anyhow::Result<AppManifest> {
let load_from_source = || async {
match source {
AppManifestSource::AsyncReadSource(mut stdin) => {
let mut buffer = String::new();
stdin
.read_to_string(&mut buffer)
.await
.context("failed to read model from stdin")?;
if buffer.is_empty() {
bail!("unable to load app manifest from empty stdin input")
}
Ok(AppManifest::SerializedModel(
serde_yaml::from_str(&buffer).context("failed to parse yaml from STDIN")?,
))
}
AppManifestSource::File(path) => {
let mut manifest = AppManifest::SerializedModel(
serde_yaml::from_str(
tokio::fs::read_to_string(&path)
.await
.context("failed to read model from file")?
.as_str(),
)
.with_context(|| {
format!("failed to parse yaml from file @ [{}]", path.display())
})?,
);
manifest.resolve_image_relative_file_paths(
path.canonicalize()
.context("failed to canonicalize path to app manifest")?
.parent()
.context("failed to get parent directory of app manifest")?,
)?;
Ok(manifest)
}
AppManifestSource::Url(url) => {
let res = tls::DEFAULT_REQWEST_CLIENT
.get(url.clone())
.send()
.await
.context("request to remote model file failed")?;
let text = res
.text()
.await
.context("failed to read model from remote file")?;
serde_yaml::from_str(&text)
.with_context(|| format!("failed to parse YAML from URL [{url}]"))
.map(AppManifest::SerializedModel)
}
AppManifestSource::Model(name) => Ok(AppManifest::ModelName(name)),
}
};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1);
tokio::time::timeout(DEFAULT_TIMEOUT, load_from_source())
.await
.context("app manifest loader timed out")?
}
#[cfg(test)]
mod test {
use super::*;
use tempfile::tempdir;
use anyhow::Result;
#[test]
#[cfg_attr(
not(can_reach_raw_githubusercontent_com),
ignore = "raw.githubusercontent.com is not reachable"
)]
fn test_app_manifest_source_from_str() -> Result<(), Box<dyn std::error::Error>> {
let stdin = AppManifestSource::from_str("-")?;
assert!(
matches!(stdin, AppManifestSource::AsyncReadSource(_)),
"expected AppManifestSource::AsyncReadSource"
);
let tmp_dir = tempdir()?;
std::fs::write(tmp_dir.path().join("foo.yaml"), "foo")?;
std::fs::write(tmp_dir.path().join("foo.toml"), "foo")?;
let file = AppManifestSource::from_str(tmp_dir.path().join("foo.yaml").to_str().unwrap())?;
assert!(
matches!(file, AppManifestSource::File(_)),
"expected AppManifestSource::File"
);
let url = AppManifestSource::from_str(
"https://raw.githubusercontent.com/wasmCloud/wasmCloud/main/examples/rust/components/http-hello-world/wadm.yaml",
)?;
assert!(
matches!(url, AppManifestSource::Url(_)),
"expected AppManifestSource::Url"
);
let url = AppManifestSource::from_str(
"https://raw.githubusercontent.com/wasmCloud/wasmCloud/main/examples/rust/components/http-hello-world/wadm.yaml",
)?;
assert!(
matches!(url, AppManifestSource::Url(_)),
"expected AppManifestSource::Url"
);
let model = AppManifestSource::from_str("foo")?;
assert!(
matches!(model, AppManifestSource::Model(_)),
"expected AppManifestSource::Model"
);
let invalid = AppManifestSource::from_str("foo.bar");
assert!(
invalid.is_err(),
"expected error on invalid app manifest model name"
);
let invalid = AppManifestSource::from_str("sftp://foobar.com");
assert!(
invalid.is_err(),
"expected error on invalid app manifest url source"
);
let invalid =
AppManifestSource::from_str(tmp_dir.path().join("foo.json").to_str().unwrap());
assert!(
invalid.is_err(),
"expected error on invalid app manifest file source"
);
let invalid =
AppManifestSource::from_str(tmp_dir.path().join("foo.toml").to_str().unwrap());
assert!(
invalid.is_err(),
"expected error on invalid app manifest file source"
);
Ok(())
}
#[tokio::test]
#[cfg_attr(
not(can_reach_raw_githubusercontent_com),
ignore = "raw.githubusercontent.com is not reachable"
)]
async fn test_load_app_manifest() -> Result<()> {
let stdin = AppManifestSource::AsyncReadSource(Box::new(std::io::Cursor::new(
"iam batman!".as_bytes(),
)));
let manifest = load_app_manifest(stdin).await?;
assert!(
matches!(manifest, AppManifest::SerializedModel(manifest) if manifest == "iam batman!"),
"expected AppManifest::SerializedModel('iam batman!')"
);
let tmp_dir = tempdir()?;
std::fs::write(tmp_dir.path().join("foo.yaml"), "foo")?;
let file = AppManifestSource::from_str(tmp_dir.path().join("foo.yaml").to_str().unwrap())?;
let manifest = load_app_manifest(file).await?;
assert!(
matches!(manifest, AppManifest::SerializedModel(manifest) if manifest == "foo"),
"expected AppManifest::SerializedModel('foo')"
);
let url = AppManifestSource::from_str(
"https://raw.githubusercontent.com/wasmCloud/wasmCloud/main/examples/rust/components/http-hello-world/wadm.yaml",
)?;
let manifest = load_app_manifest(url).await?;
assert!(
matches!(manifest, AppManifest::SerializedModel(_)),
"expected AppManifest::SerializedModel(_)"
);
let model = AppManifestSource::from_str("foo")?;
let manifest = load_app_manifest(model).await?;
assert!(
matches!(manifest, AppManifest::ModelName(name) if name == "foo"),
"expected AppManifest::ModelName('foo')"
);
Ok(())
}
}