wasmer_sdk/app/
deploy.rs

1use std::time::Duration;
2
3use anyhow::Context as _;
4use reqwest;
5use thiserror::Error;
6use wasmer_backend_api::{
7    WasmerClient,
8    types::{DeployApp, DeployAppVersion, PublishDeployAppVars},
9};
10use wasmer_config::{app::AppConfigV1, package::PackageSource};
11
12use crate::package::publish::{
13    PackagePublishError, PublishOptions, PublishProgress, PublishWait, publish_package_directory,
14};
15
16/// When waiting for an app deployment.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum WaitMode {
19    /// Wait for the deployment to be created.
20    Deployed,
21    /// Wait until the deployment becomes reachable over the network.
22    Reachable,
23}
24
25/// Progress events during deployment.
26#[derive(Debug, Clone)]
27pub enum DeployProgress {
28    Publishing(PublishProgress),
29    Deploying,
30    Waiting(WaitMode),
31}
32
33/// Options for deploying an app.
34#[derive(Debug, Clone)]
35pub struct DeployOptions {
36    pub owner: Option<String>,
37    pub make_default: bool,
38    pub wait: WaitMode,
39    pub publish_package: bool,
40    pub package_namespace: Option<String>,
41    pub publish_timeout: Duration,
42}
43
44impl Default for DeployOptions {
45    fn default() -> Self {
46        Self {
47            owner: None,
48            make_default: true,
49            wait: WaitMode::Reachable,
50            publish_package: true,
51            package_namespace: None,
52            publish_timeout: Duration::from_secs(60 * 5),
53        }
54    }
55}
56
57/// Error that can occur during app deployment.
58#[derive(Debug, Error)]
59#[non_exhaustive]
60pub enum DeployError {
61    #[error("missing owner in configuration or options")]
62    MissingOwner,
63    #[error("missing app name in configuration")]
64    MissingName,
65    #[error("package publish error: {0}")]
66    Publish(#[from] PackagePublishError),
67    #[error("yaml error: {0}")]
68    Yaml(#[from] serde_yaml::Error),
69    #[error("backend API error: {0}")]
70    Api(#[from] anyhow::Error),
71    #[error("app http probe failed: {message}")]
72    AppHttpCheck { message: String },
73}
74
75/// Deploy an app based on the provided app configuration and options.
76pub async fn deploy_app<F>(
77    client: &WasmerClient,
78    mut config: AppConfigV1,
79    opts: DeployOptions,
80    mut progress: F,
81) -> Result<(DeployApp, DeployAppVersion), DeployError>
82where
83    F: FnMut(DeployProgress) + Send,
84{
85    let owner = opts
86        .owner
87        .clone()
88        .or_else(|| config.owner.clone())
89        .ok_or(DeployError::MissingOwner)?;
90
91    if opts.publish_package {
92        if let PackageSource::Path(ref path) = config.package {
93            let publish_opts = PublishOptions {
94                namespace: Some(
95                    opts.package_namespace
96                        .clone()
97                        .unwrap_or_else(|| owner.clone()),
98                ),
99                timeout: opts.publish_timeout,
100                wait: PublishWait::Container,
101                ..Default::default()
102            };
103            let ident = publish_package_directory(client, path.as_ref(), publish_opts, |e| {
104                progress(DeployProgress::Publishing(e));
105            })
106            .await?;
107            config.package = ident.into();
108        }
109    }
110
111    let name = config.name.clone().ok_or(DeployError::MissingName)?;
112
113    let config_value = config.clone().to_yaml_value()?;
114    let raw_config = serde_yaml::to_string(&config_value)?.trim().to_string() + "\n";
115
116    progress(DeployProgress::Deploying);
117    let version = wasmer_backend_api::query::publish_deploy_app(
118        client,
119        PublishDeployAppVars {
120            config: raw_config,
121            name: name.clone().into(),
122            owner: Some(owner.clone().into()),
123            make_default: Some(opts.make_default),
124        },
125    )
126    .await?;
127
128    progress(DeployProgress::Waiting(opts.wait));
129    wait_app(client, &version, opts.wait, opts.make_default).await
130}
131
132async fn wait_app(
133    client: &WasmerClient,
134    version: &DeployAppVersion,
135    wait: WaitMode,
136    make_default: bool,
137) -> Result<(DeployApp, DeployAppVersion), DeployError> {
138    const PROBE_TIMEOUT: Duration = Duration::from_secs(60 * 5);
139    use wasmer_config::app::HEADER_APP_VERSION_ID;
140
141    let app_id = version
142        .app
143        .as_ref()
144        .ok_or_else(|| DeployError::Api(anyhow::anyhow!("app field empty")))?
145        .id
146        .inner()
147        .to_string();
148
149    let app = wasmer_backend_api::query::get_app_by_id(client, app_id.clone())
150        .await
151        .map_err(DeployError::Api)?;
152
153    match wait {
154        WaitMode::Deployed => {}
155        WaitMode::Reachable => {
156            tokio::time::sleep(Duration::from_secs(2)).await;
157            let check_url = if make_default { &app.url } else { &version.url };
158            let http = reqwest::Client::builder()
159                .connect_timeout(Duration::from_secs(10))
160                .timeout(Duration::from_secs(90))
161                .redirect(reqwest::redirect::Policy::none())
162                .build()
163                .context("failed to build HTTP client")?;
164            let start = tokio::time::Instant::now();
165            let mut sleep_ms = 1_000u64;
166            loop {
167                if start.elapsed() > Duration::from_secs(60 * 5) {
168                    return Err(DeployError::AppHttpCheck {
169                        message: format!(
170                            "timed out waiting for app version '{}' to become reachable at '{}' (tried for {} seconds)",
171                            version.id.inner(),
172                            check_url,
173                            PROBE_TIMEOUT.as_secs(),
174                        ),
175                    });
176                }
177                if let Ok(res) = http.get(check_url).send().await {
178                    let header = res
179                        .headers()
180                        .get(HEADER_APP_VERSION_ID)
181                        .and_then(|v| v.to_str().ok())
182                        .unwrap_or("");
183                    if header == version.id.inner() {
184                        break;
185                    }
186                }
187                let to_sleep = Duration::from_millis(sleep_ms);
188                tokio::time::sleep(to_sleep).await;
189                sleep_ms = (sleep_ms * 2).min(10_000);
190            }
191        }
192    }
193
194    Ok((app, version.clone()))
195}