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