1use 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#[derive(Debug, Clone)]
27pub struct DeployRemoteOpts {
28 pub app: AppConfigV1,
29 pub owner: Option<String>,
30}
31
32#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub enum DeployRemoteEvent {
38 CreatingArchive {
40 path: PathBuf,
41 },
42 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#[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 #[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
95pub 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 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 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}