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