pub mod create;
pub mod delete;
pub mod get;
pub mod list;
pub mod logs;
use std::{io::Write, time::Duration};
use anyhow::{bail, Context};
use wasmer_api::backend::{
global_id::{GlobalId, NodeKind},
gql::{DeployApp, DeployAppVersion},
BackendClient,
};
use wasmer_deploy_schema::schema::AppConfigV1;
use crate::cmd::AsyncCliCommand;
#[derive(clap::Subcommand, Debug)]
pub enum CmdApp {
Get(get::CmdAppGet),
Info(get::CmdAppInfo),
List(list::CmdAppList),
Logs(logs::CmdAppLogs),
Create(create::CmdAppCreate),
Delete(delete::CmdAppDelete),
}
impl AsyncCliCommand for CmdApp {
fn run_async(self) -> futures::future::BoxFuture<'static, Result<(), anyhow::Error>> {
match self {
Self::Get(cmd) => cmd.run_async(),
Self::Info(cmd) => cmd.run_async(),
Self::Create(cmd) => cmd.run_async(),
Self::List(cmd) => cmd.run_async(),
Self::Logs(cmd) => cmd.run_async(),
Self::Delete(cmd) => cmd.run_async(),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
enum AppIdent {
AppId(String),
NamespacedName(String, String),
Name(String),
}
impl AppIdent {
async fn resolve(&self, client: &BackendClient) -> Result<DeployApp, anyhow::Error> {
match self {
AppIdent::AppId(app_id) => wasmer_api::backend::get_app_by_id(client, app_id.clone())
.await
.with_context(|| format!("Could not find app with id '{}'", app_id)),
AppIdent::Name(name) => wasmer_api::backend::get_app_by_alias(client, name.clone())
.await?
.with_context(|| format!("Could not find app with name '{name}'")),
AppIdent::NamespacedName(owner, name) => {
wasmer_api::backend::get_app(client, owner.clone(), name.clone())
.await?
.with_context(|| format!("Could not find app '{owner}/{name}'"))
}
}
}
}
impl std::str::FromStr for AppIdent {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((namespace, name)) = s.split_once('/') {
if namespace.is_empty() {
bail!("invalid app identifier '{s}': namespace can not be empty");
}
if name.is_empty() {
bail!("invalid app identifier '{s}': name can not be empty");
}
Ok(Self::NamespacedName(
namespace.to_string(),
name.to_string(),
))
} else if let Ok(id) = GlobalId::parse_prefixed(s) {
if id.kind() == NodeKind::DeployApp {
Ok(Self::AppId(s.to_string()))
} else {
bail!(
"invalid app identifier '{s}': expected an app id, but id is of type {kind}",
kind = id.kind(),
);
}
} else {
Ok(Self::Name(s.to_string()))
}
}
}
pub struct DeployAppOpts<'a> {
pub app: &'a AppConfigV1,
pub original_config: Option<serde_yaml::Value>,
pub allow_create: bool,
pub make_default: bool,
pub owner: Option<String>,
pub wait: WaitMode,
}
pub async fn deploy_app(
client: &BackendClient,
opts: DeployAppOpts<'_>,
) -> Result<DeployAppVersion, anyhow::Error> {
let app = opts.app;
let config_value = app.clone().to_yaml_value()?;
let final_config = if let Some(old) = &opts.original_config {
crate::util::merge_yaml_values(old, &config_value)
} else {
config_value
};
let mut raw_config = serde_yaml::to_string(&final_config)?.trim().to_string();
raw_config.push('\n');
let version = wasmer_api::backend::publish_deploy_app(
client,
wasmer_api::backend::gql::PublishDeployAppVars {
config: raw_config,
name: app.name.clone().into(),
owner: opts.owner.map(|o| o.into()),
make_default: Some(opts.make_default),
},
)
.await
.context("could not create app in the backend")?;
Ok(version)
}
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum WaitMode {
Deployed,
Reachable,
}
pub async fn deploy_app_verbose(
client: &BackendClient,
mut opts: DeployAppOpts<'_>,
) -> Result<(DeployApp, DeployAppVersion), anyhow::Error> {
let owner = &opts.owner;
let app = &opts.app;
let pretty_name = if let Some(owner) = &owner {
format!("{}/{}", owner, app.name)
} else {
app.name.clone()
};
let make_default = opts.make_default;
eprintln!("Deploying app {pretty_name}...\n");
let (owner, app_opt) = if let Some(owner) = owner {
(Some(owner.clone()), None)
} else if let Some(id) = &app.app_id {
let app = wasmer_api::backend::get_app_by_id(client, id.clone())
.await
.context("could not fetch app from backend")?;
(Some(app.owner.global_name.clone()), Some(app))
} else {
(None, None)
};
opts.owner = owner;
let wait = opts.wait;
let version = deploy_app(client, opts).await?;
let app_id = version
.app
.as_ref()
.context("app field on app version is empty")?
.id
.inner()
.to_string();
let app = if let Some(app) = app_opt {
app
} else {
wasmer_api::backend::get_app_by_id(client, app_id.clone())
.await
.context("could not fetch app from backend")?
};
let full_name = format!("{}/{}", app.owner.global_name, app.name);
eprintln!(" ✅ App {full_name} was successfully deployed!");
eprintln!();
eprintln!("> App URL: {}", app.url);
eprintln!("> Versioned URL: {}", version.url);
eprintln!("> Admin dashboard: {}", app.admin_url);
match wait {
WaitMode::Deployed => {}
WaitMode::Reachable => {
eprintln!();
eprintln!("Waiting for new deployment to become available...");
eprintln!("(You can safely stop waiting now with CTRL-C)");
let stderr = std::io::stderr();
tokio::time::sleep(Duration::from_secs(2)).await;
let start = tokio::time::Instant::now();
let client = reqwest::Client::new();
let check_url = if make_default { &app.url } else { &version.url };
let mut sleep_millis: u64 = 1_000;
loop {
let total_elapsed = start.elapsed();
if total_elapsed > Duration::from_secs(60 * 5) {
eprintln!();
bail!("\nApp still not reachable after 5 minutes...");
}
{
let mut lock = stderr.lock();
write!(&mut lock, ".").unwrap();
lock.flush().unwrap();
}
let request_start = tokio::time::Instant::now();
match client.get(check_url).send().await {
Ok(res) => {
let header = res
.headers()
.get(wasmer_deploy_util::headers::HEADER_APP_VERSION_ID)
.and_then(|x| x.to_str().ok())
.unwrap_or_default();
if header == version.id.inner() {
eprintln!("\nNew version is now reachable at {check_url}");
eprintln!("Deployment complete");
break;
}
tracing::debug!(
current=%header,
expected=%app.active_version.id.inner(),
"app is not at the right version yet",
);
}
Err(err) => {
tracing::debug!(?err, "health check request failed");
}
};
let elapsed: u64 = request_start
.elapsed()
.as_millis()
.try_into()
.unwrap_or_default();
let to_sleep = Duration::from_millis(sleep_millis.saturating_sub(elapsed));
tokio::time::sleep(to_sleep).await;
sleep_millis = (sleep_millis * 2).max(10_000);
}
}
}
Ok((app, version))
}
pub fn app_config_from_api(version: &DeployAppVersion) -> Result<AppConfigV1, anyhow::Error> {
let app_id = version
.app
.as_ref()
.context("app field on app version is empty")?
.id
.inner()
.to_string();
let cfg = &version.user_yaml_config;
let mut cfg = AppConfigV1::parse_yaml(cfg)
.context("could not parse app config from backend app version")?;
cfg.app_id = Some(app_id);
Ok(cfg)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn test_app_ident() {
assert_eq!(
AppIdent::from_str("da_MRrWI0t5U582").unwrap(),
AppIdent::AppId("da_MRrWI0t5U582".to_string()),
);
assert_eq!(
AppIdent::from_str("lala").unwrap(),
AppIdent::Name("lala".to_string()),
);
assert_eq!(
AppIdent::from_str("alpha/beta").unwrap(),
AppIdent::NamespacedName("alpha".to_string(), "beta".to_string()),
);
}
}