wasmer_sdk/app/
deploy_remote_build.rs

1//! Functionality for deploying applications to Wasmer Edge through the
2//! "autobuild zip upload" flow, which just uploads a zip directory and
3//! lets the Wasmer backend handle building and deploying it.
4
5use std::{
6    path::{Path, PathBuf},
7    time::Duration,
8};
9
10use anyhow::{Context as _, bail};
11use futures_util::StreamExt;
12use reqwest::header::CONTENT_TYPE;
13use wasmer_backend_api::{
14    WasmerClient,
15    types::{AutoBuildDeployAppLogKind, AutobuildLog, DeployAppVersion, Id},
16};
17use wasmer_config::app::AppConfigV1;
18use zip::{CompressionMethod, write::SimpleFileOptions};
19
20use thiserror::Error;
21
22pub use wasmer_backend_api::types::BuildConfig;
23
24/// Options for remote deployments through [`deploy_app_remote`].
25#[derive(Debug, Clone)]
26pub struct DeployRemoteOpts {
27    pub app: AppConfigV1,
28    pub owner: Option<String>,
29}
30
31/// Events emitted during the remote deployment process.
32///
33/// Used by the `on_progress` callback in [`deploy_app_remote`].
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum DeployRemoteEvent {
37    /// Starting creation of the archive file.
38    CreatingArchive {
39        path: PathBuf,
40    },
41    /// Archive file has been created.
42    ArchiveCreated {
43        file_count: usize,
44        archive_size: u64,
45    },
46    GeneratingUploadUrl,
47    UploadArchiveStart {
48        archive_size: u64,
49    },
50    DeterminingBuildConfiguration,
51    BuildConfigDetermined {
52        config: BuildConfig,
53    },
54    InitiatingBuild {
55        vars: wasmer_backend_api::types::DeployViaAutobuildVars,
56    },
57    StreamingAutobuildLogs {
58        build_id: String,
59    },
60    AutobuildLog {
61        log: AutobuildLog,
62    },
63    Finished,
64}
65
66/// Errors that can occur during remote deployments.
67#[derive(Debug, Error)]
68pub enum DeployRemoteError {
69    #[error("deployment directory '{0}' does not exist")]
70    MissingDeploymentDirectory(String),
71    #[error("owner must be specified for remote deployments")]
72    MissingOwner,
73    #[error("remote deployments require `app.yaml` to define either an app name or an app_id")]
74    MissingAppIdentifier,
75    #[error("remote deployment request was rejected by the API")]
76    RequestRejected,
77    #[error("remote deployment failed: {0}")]
78    DeploymentFailed(String),
79    // TODO: should not use anyhow here... but backend-api crate does.
80    #[error("backend API error: {0}")]
81    Api(anyhow::Error),
82    #[error(transparent)]
83    Http(#[from] reqwest::Error),
84    #[error("remote deployment completed but no app version was returned")]
85    MissingAppVersion,
86    #[error("remote deployment stream ended without a completion event")]
87    MissingCompletionEvent,
88    #[error("zip archive creation failed: {0}")]
89    ZipCreation(anyhow::Error),
90    #[error("unexpected error: {0}")]
91    Other(Box<dyn std::error::Error + Send + Sync>),
92}
93
94/// Deploy an application using the remote autobuild zip upload flow.
95///
96/// It will build a ZIP archive of the specified `base_dir`, upload it to Wasmer,
97/// and request an autobuild deployment.
98///
99/// The Wasmer backend will handle building and deploying the application.
100pub async fn deploy_app_remote<F>(
101    client: &WasmerClient,
102    opts: DeployRemoteOpts,
103    base_dir: &Path,
104    mut on_progress: F,
105) -> Result<DeployAppVersion, DeployRemoteError>
106where
107    F: FnMut(DeployRemoteEvent) + Send,
108{
109    if !base_dir.is_dir() {
110        return Err(DeployRemoteError::MissingDeploymentDirectory(
111            base_dir.display().to_string(),
112        ));
113    }
114
115    let app = opts.app;
116    let owner = opts
117        .owner
118        .clone()
119        .or_else(|| app.owner.clone())
120        .ok_or(DeployRemoteError::MissingOwner)?;
121
122    let app_name = app.name.clone();
123    let app_id = app.app_id.clone();
124    if app_name.is_none() && app_id.is_none() {
125        return Err(DeployRemoteError::MissingAppIdentifier);
126    }
127
128    on_progress(DeployRemoteEvent::CreatingArchive {
129        path: base_dir.to_path_buf(),
130    });
131
132    let archive = tokio::task::spawn_blocking({
133        let base_dir = base_dir.to_path_buf();
134        move || create_zip_archive(&base_dir)
135    })
136    .await
137    .map_err(|e| DeployRemoteError::Other(e.into()))?
138    .map_err(DeployRemoteError::ZipCreation)?;
139    on_progress(DeployRemoteEvent::ArchiveCreated {
140        file_count: archive.file_count,
141        archive_size: archive.bytes.len() as u64,
142    });
143
144    let UploadArchive { bytes, .. } = archive;
145
146    let base_for_filename = app_name.as_deref().or(app_id.as_deref()).unwrap();
147    let filename = format!("{}-upload.zip", sanitize_archive_name(base_for_filename));
148
149    on_progress(DeployRemoteEvent::GeneratingUploadUrl);
150
151    let signed_url = wasmer_backend_api::query::generate_upload_url(
152        client,
153        &filename,
154        app_name.as_deref(),
155        None,
156        Some(300),
157    )
158    .await
159    .map_err(DeployRemoteError::Api)?;
160
161    on_progress(DeployRemoteEvent::UploadArchiveStart {
162        archive_size: bytes.len() as u64,
163    });
164
165    let http_client = reqwest::Client::builder()
166        .connect_timeout(Duration::from_secs(10))
167        .build()
168        .map_err(|e| DeployRemoteError::Other(e.into()))?;
169
170    tracing::debug!("uploading archive to signed URL: {}", signed_url.url);
171    http_client
172        .put(&signed_url.url)
173        .header(CONTENT_TYPE, "application/zip")
174        .body(bytes)
175        .send()
176        .await?
177        .error_for_status()?;
178
179    let upload_url = signed_url.url;
180    on_progress(DeployRemoteEvent::DeterminingBuildConfiguration);
181    let config_res =
182        wasmer_backend_api::query::autobuild_config_for_zip_upload(client, &upload_url)
183            .await
184            .context("failed to query autobuild config for uploaded archive")
185            .map_err(DeployRemoteError::Api)?
186            .context("no autobuild config found for uploaded archive")
187            .map_err(DeployRemoteError::Api)?;
188    let config = config_res
189        .build_config
190        .context(
191            "Could not determine appropriate build config - project does not seem to be supported.",
192        )
193        .map_err(DeployRemoteError::Api)?;
194    tracing::debug!(?config, "determined build config");
195    on_progress(DeployRemoteEvent::BuildConfigDetermined {
196        config: config.clone(),
197    });
198
199    let app_id_value = app_id.as_ref().map(|id| Id::from(id.clone()));
200    let domains: Option<Vec<Option<String>>> = app
201        .domains
202        .clone()
203        .map(|d| d.into_iter().map(Some).collect::<Vec<_>>())
204        .filter(|d| !d.is_empty());
205
206    let vars = wasmer_backend_api::types::DeployViaAutobuildVars {
207        repo_url: None,
208        upload_url: Some(upload_url),
209        app_name: app_name.clone(),
210        app_id: app_id_value,
211        owner: Some(owner),
212        build_cmd: Some(String::new()),
213        install_cmd: Some(String::new()),
214        enable_database: Some(false),
215        secrets: Some(vec![]),
216        extra_data: None,
217        params: None,
218        managed: None,
219        kind: None,
220        wait_for_screenshot_generation: Some(false),
221        region: None,
222        branch: None,
223        allow_existing_app: Some(true),
224        jobs: None,
225        domains,
226        client_mutation_id: None,
227    };
228    on_progress(DeployRemoteEvent::InitiatingBuild { vars: vars.clone() });
229    let deploy_response = wasmer_backend_api::query::deploy_via_autobuild(client, vars)
230        .await
231        .map_err(DeployRemoteError::Api)?
232        .context("deployViaAutobuild mutation did not return data")
233        .map_err(DeployRemoteError::Api)?;
234
235    if !deploy_response.success {
236        return Err(DeployRemoteError::RequestRejected);
237    }
238
239    let build_id = deploy_response.build_id.0;
240
241    on_progress(DeployRemoteEvent::StreamingAutobuildLogs {
242        build_id: build_id.clone(),
243    });
244
245    let mut final_version: Option<DeployAppVersion> = None;
246    'OUTER: loop {
247        let mut stream = wasmer_backend_api::subscription::autobuild_deployment(client, &build_id)
248            .await
249            .map_err(DeployRemoteError::Api)?;
250
251        while let Some(event) = stream.next().await {
252            tracing::debug!(?event, "received autobuild event");
253            let event = event.map_err(|err| DeployRemoteError::Other(err.into()))?;
254            if let Some(data) = event.data {
255                if let Some(log) = data.autobuild_deployment {
256                    on_progress(DeployRemoteEvent::AutobuildLog { log: log.clone() });
257                    let message = log.message.clone();
258                    let kind = log.kind;
259
260                    match kind {
261                        AutoBuildDeployAppLogKind::Failed => {
262                            let msg = message.unwrap_or_else(|| "remote deployment failed".into());
263                            return Err(DeployRemoteError::DeploymentFailed(msg));
264                        }
265                        AutoBuildDeployAppLogKind::Complete => {
266                            let version = log
267                                .app_version
268                                .ok_or(DeployRemoteError::MissingAppVersion)?;
269
270                            final_version = Some(version);
271                            break 'OUTER;
272                        }
273                        _ => {}
274                    }
275                }
276            }
277        }
278
279        if final_version.is_some() {
280            break;
281        }
282        tracing::warn!("autobuild event stream ended, reconnecting...");
283    }
284
285    let version = final_version.ok_or(DeployRemoteError::MissingCompletionEvent)?;
286
287    on_progress(DeployRemoteEvent::Finished);
288
289    Ok(version)
290}
291
292struct UploadArchive {
293    bytes: Vec<u8>,
294    file_count: usize,
295}
296
297fn create_zip_archive(base_dir: &Path) -> Result<UploadArchive, anyhow::Error> {
298    let mut file_count = 0usize;
299    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
300
301    let walker = {
302        let mut b = ignore::WalkBuilder::new(base_dir);
303
304        b.standard_filters(true)
305            .ignore(true)
306            .git_ignore(true)
307            .git_exclude(true)
308            .git_global(true)
309            .require_git(true)
310            .parents(true)
311            .follow_links(false);
312
313        // Ignore .shipit directories, since they are for local use only.
314        let mut overrides = ignore::overrides::OverrideBuilder::new(".");
315        overrides.add("!.shipit").expect("valid override");
316        b.overrides(overrides.build()?);
317
318        b.build()
319    };
320
321    let entries = walker.into_iter();
322    for entry in entries {
323        let entry = entry?;
324
325        let ty = entry.file_type().ok_or_else(|| {
326            anyhow::anyhow!(
327                "failed to determine file type for '{}'",
328                entry.path().display()
329            )
330        })?;
331
332        let rel_path = entry.path().strip_prefix(base_dir)?;
333
334        if ty.is_symlink() {
335            bail!(
336                "cannot deploy projects containing symbolic links (found '{}')",
337                rel_path.display()
338            );
339        }
340
341        let rel_str = rel_path.to_string_lossy().replace('\\', "/");
342
343        if ty.is_dir() {
344            writer.add_directory(format!("{rel_str}/"), SimpleFileOptions::default())?;
345        } else if ty.is_file() {
346            file_count += 1;
347            writer.start_file(
348                rel_str,
349                SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
350            )?;
351            let mut file = std::fs::File::open(entry.path())?;
352            std::io::copy(&mut file, &mut writer)?;
353        }
354    }
355
356    let cursor = writer.finish()?;
357    let bytes = cursor.into_inner();
358
359    Ok(UploadArchive { bytes, file_count })
360}
361
362fn sanitize_archive_name(input: &str) -> String {
363    let slug = input
364        .chars()
365        .map(|c| {
366            if c.is_ascii_alphanumeric() {
367                c.to_ascii_lowercase()
368            } else {
369                '-'
370            }
371        })
372        .collect::<String>();
373
374    let slug = slug.trim_matches('-');
375
376    if slug.is_empty() {
377        "app".to_string()
378    } else {
379        slug.to_string()
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::{collections::HashSet, fs, io::Cursor, path::Path};
387    use tempfile::TempDir;
388
389    #[test]
390    fn create_zip_archive_respects_ignore_files() -> anyhow::Result<()> {
391        let project = create_sample_project()?;
392        let archive = create_zip_archive(project.path())?;
393
394        let names = archive_file_names(&archive.bytes)?;
395
396        assert!(names.contains("app.yaml"));
397        assert!(names.contains("keep_dir/keep.txt"));
398        // .wasmerignore should *not* be taken into account for remote builds
399        assert!(names.contains("custom.txt"));
400        assert!(!names.contains("ignored.txt"));
401        assert!(!names.contains("ignored_dir/file.txt"));
402
403        Ok(())
404    }
405
406    fn archive_file_names(bytes: &[u8]) -> anyhow::Result<HashSet<String>> {
407        let cursor = Cursor::new(bytes);
408        let mut archive = zip::ZipArchive::new(cursor)?;
409        let mut names = HashSet::new();
410
411        for idx in 0..archive.len() {
412            let file = archive.by_index(idx)?;
413            names.insert(file.name().to_string());
414        }
415
416        Ok(names)
417    }
418
419    fn create_sample_project() -> anyhow::Result<TempDir> {
420        let dir = tempfile::tempdir()?;
421        populate_project(dir.path())?;
422        Ok(dir)
423    }
424
425    fn populate_project(base: &Path) -> anyhow::Result<()> {
426        fs::create_dir_all(base.join(".git"))?;
427        fs::write(base.join("app.yaml"), "name = \"demo\"\n")?;
428        fs::write(base.join(".gitignore"), "ignored.txt\nignored_dir/\n")?;
429        fs::write(base.join(".wasmerignore"), "custom.txt\n")?;
430        fs::write(base.join("ignored.txt"), "ignore me")?;
431        fs::write(base.join("custom.txt"), "ignore me too")?;
432        fs::write(base.join("keep.txt"), "keep me")?;
433        fs::create_dir_all(base.join("ignored_dir"))?;
434        fs::write(base.join("ignored_dir/file.txt"), "ignored dir file")?;
435        fs::create_dir_all(base.join("keep_dir"))?;
436        fs::write(base.join("keep_dir/keep.txt"), "keep dir file")?;
437        Ok(())
438    }
439}