1use super::{AsyncCliCommand, util::login_user};
2use crate::{
3 commands::{
4 PublishWait,
5 app::create::{CmdAppCreate, minimal_app_config, write_app_config},
6 package::publish::PackagePublish,
7 },
8 config::WasmerEnv,
9 opts::ItemFormatOpts,
10 utils::{DEFAULT_PACKAGE_MANIFEST_FILE, load_package_manifest},
11};
12use anyhow::Context;
13use bytesize::ByteSize;
14use colored::Colorize;
15use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};
16use dialoguer::{Confirm, theme::ColorfulTheme};
17use indexmap::IndexMap;
18use std::io::IsTerminal as _;
19use std::io::Write;
20use std::{path::Path, path::PathBuf, str::FromStr, time::Duration};
21use time::{Duration as TimeDuration, OffsetDateTime, format_description};
22use wasmer_backend_api::{
23 WasmerClient,
24 types::{
25 AutoBuildDeployAppLogKind, DeployApp, DeployAppVersion, DeployDeployAppPerishReasonChoices,
26 },
27};
28use wasmer_config::{
29 app::AppConfigV1,
30 package::{PackageIdent, PackageSource},
31};
32use wasmer_sdk::app::deploy_remote_build::{
33 DeployRemoteEvent, DeployRemoteOpts, deploy_app_remote,
34};
35
36static EDGE_HEADER_APP_VERSION_ID: http::HeaderName =
37 http::HeaderName::from_static("x-edge-app-version-id");
38
39#[derive(clap::Parser, Debug)]
41pub struct CmdAppDeploy {
42 #[clap(flatten)]
43 pub env: WasmerEnv,
44
45 #[clap(flatten)]
46 pub fmt: ItemFormatOpts,
47
48 #[clap(long)]
50 pub no_validate: bool,
51
52 #[clap(long, default_value_t = !std::io::stdin().is_terminal())]
54 pub non_interactive: bool,
55
56 #[clap(long)]
60 pub publish_package: bool,
61
62 #[clap(long)]
64 pub dir: Option<PathBuf>,
65
66 #[clap(long, conflicts_with = "dir")]
68 pub path: Option<PathBuf>,
69
70 #[clap(long)]
72 pub no_wait: bool,
73
74 #[clap(long)]
77 pub no_default: bool,
78
79 #[clap(long)]
81 pub no_persist_id: bool,
82
83 #[clap(long)]
90 pub owner: Option<String>,
91
92 #[clap(long, name = "name")]
98 pub app_name: Option<String>,
99
100 #[clap(long)]
102 pub bump: bool,
103
104 #[clap(long)]
109 pub quiet: bool,
110
111 #[clap(long)]
113 pub build_remote: bool,
114
115 #[clap(
122 long,
123 conflicts_with = "package",
124 conflicts_with = "use_local_manifest"
125 )]
126 pub template: Option<String>,
127
128 #[clap(
130 long,
131 conflicts_with = "template",
132 conflicts_with = "use_local_manifest"
133 )]
134 pub package: Option<String>,
135
136 #[clap(long, conflicts_with = "template", conflicts_with = "package")]
138 pub use_local_manifest: bool,
139
140 #[clap(skip)]
141 pub ensure_app_config: bool,
142}
143
144struct RemoteBuildInput {
145 app_config: AppConfigV1,
146 owner: String,
147 original_config: Option<serde_yaml::Value>,
148 config_path: Option<PathBuf>,
149}
150
151impl CmdAppDeploy {
152 async fn publish(
153 &self,
154 client: &WasmerClient,
155 owner: String,
156 manifest_dir_path: PathBuf,
157 ) -> anyhow::Result<PackageIdent> {
158 let (manifest_path, manifest) = match load_package_manifest(&manifest_dir_path)? {
159 Some(r) => r,
160 None => anyhow::bail!(
161 "Could not read or find wasmer.toml manifest in path '{}'!",
162 manifest_dir_path.display()
163 ),
164 };
165
166 let publish_cmd = PackagePublish {
167 env: self.env.clone(),
168 dry_run: false,
169 quiet: self.quiet,
170 package_name: None,
171 package_version: None,
172 no_validate: false,
173 package_path: manifest_dir_path.clone(),
174 wait: match self.no_wait {
175 true => PublishWait::None,
176 false => PublishWait::Container,
177 },
178 timeout: humantime::Duration::from_str("2m").unwrap(),
179 package_namespace: Some(owner),
180 non_interactive: self.non_interactive,
181 bump: self.bump,
182 };
183
184 publish_cmd
185 .publish(client, &manifest_path, &manifest, true)
186 .await
187 }
188
189 async fn get_owner(
190 &self,
191 client: &WasmerClient,
192 app: &mut serde_yaml::Value,
193 maybe_edge_app: Option<&DeployApp>,
194 ) -> anyhow::Result<String> {
195 if let Some(owner) = &self.owner {
196 return Ok(owner.clone());
197 }
198
199 if let Some(serde_yaml::Value::String(owner)) = &app.get("owner") {
200 return Ok(owner.clone());
201 }
202
203 if let Some(edge_app) = maybe_edge_app {
204 app.as_mapping_mut()
205 .unwrap()
206 .insert("owner".into(), edge_app.owner.global_name.clone().into());
207 return Ok(edge_app.owner.global_name.clone());
208 };
209
210 if self.non_interactive {
211 anyhow::bail!("No owner specified: use --owner XXX");
213 }
214
215 let user = wasmer_backend_api::query::current_user_with_namespaces(client, None).await?;
216 let owner = crate::utils::prompts::prompt_for_namespace(
217 "Who should own this app?",
218 None,
219 Some(&user),
220 )?;
221
222 app.as_mapping_mut()
223 .unwrap()
224 .insert("owner".into(), owner.clone().into());
225
226 Ok(owner.clone())
227 }
228 async fn create(&self) -> anyhow::Result<()> {
229 eprintln!("It seems you are trying to create a new app!");
230
231 let create_cmd = CmdAppCreate {
232 quiet: self.quiet,
233 deploy_app: false,
234 no_validate: false,
235 non_interactive: false,
236 offline: false,
237 owner: self.owner.clone(),
238 app_name: self.app_name.clone(),
239 no_wait: self.no_wait,
240 env: self.env.clone(),
241 fmt: ItemFormatOpts {
242 format: self.fmt.format,
243 },
244 package: self.package.clone(),
245 template: self.template.clone(),
246 app_dir_path: self.dir.clone(),
247 use_local_manifest: self.use_local_manifest,
248 new_package_name: None,
249 };
250
251 create_cmd.run_async().await
252 }
253
254 fn resolve_app_paths(&self) -> anyhow::Result<(PathBuf, PathBuf)> {
255 let base = if let Some(dir) = &self.dir {
256 dir.clone()
257 } else if let Some(path) = &self.path {
258 path.clone()
259 } else {
260 std::env::current_dir()
261 .context("could not determine current directory for deployment")?
262 };
263
264 if base.is_file() {
265 let base_dir = base
266 .parent()
267 .map(PathBuf::from)
268 .context("could not determine parent directory for app config")?;
269 Ok((base, base_dir))
270 } else if base.is_dir() {
271 let config = base.join(AppConfigV1::CANONICAL_FILE_NAME);
272 Ok((config, base))
273 } else {
274 anyhow::bail!("No such file or directory '{}'", base.display());
275 }
276 }
277
278 async fn handle_remote_build(&self, client: &WasmerClient) -> anyhow::Result<()> {
279 let (app_config_path, base_dir_path) = self.resolve_app_paths()?;
280 let wait = if self.no_wait {
281 WaitMode::Deployed
282 } else {
283 WaitMode::Reachable
284 };
285
286 let prep = if app_config_path.is_file() {
287 self.prepare_remote_build_from_file(client, &app_config_path, &base_dir_path)
288 .await?
289 } else {
290 self.prepare_remote_build_without_config(client, &base_dir_path)
291 .await?
292 };
293
294 let RemoteBuildInput {
295 app_config,
296 owner,
297 original_config,
298 config_path,
299 } = prep;
300
301 let opts = DeployAppOpts {
302 app: &app_config,
303 original_config: original_config.clone(),
304 allow_create: true,
305 make_default: !self.no_default,
306 owner: Some(owner.clone()),
307 wait,
308 };
309
310 let app_version = deploy_app_remote(
311 client,
312 DeployRemoteOpts {
313 app: app_config.clone(),
314 owner: Some(owner.clone()),
315 },
316 &base_dir_path,
317 remote_progress_handler(self.quiet),
318 )
319 .await?;
320
321 if let Some(path) = config_path {
322 let mut new_app_config = app_config_from_api(&app_version)?;
323
324 if self.no_persist_id {
325 new_app_config.app_id = None;
326 }
327
328 new_app_config.package = app_config.package.clone();
329
330 if new_app_config != app_config {
331 let new_merged = crate::utils::merge_yaml_values(
332 &app_config.clone().to_yaml_value()?,
333 &new_app_config.to_yaml_value()?,
334 );
335 let new_config_raw =
336 crate::utils::yaml::apply_app_config_to_yaml_file(&path, &new_merged)?;
337 std::fs::write(&path, new_config_raw)
338 .with_context(|| format!("Could not write file: '{}'", path.display()))?;
339 }
340 }
341
342 wait_app(client, opts.clone(), app_version.clone(), self.quiet).await?;
343
344 if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) {
345 println!("{}", serde_json::to_string_pretty(&app_version)?);
346 }
347
348 Ok(())
349 }
350
351 async fn prepare_remote_build_from_file(
352 &self,
353 client: &WasmerClient,
354 app_config_path: &Path,
355 base_dir_path: &Path,
356 ) -> anyhow::Result<RemoteBuildInput> {
357 let config_str = std::fs::read_to_string(app_config_path)
358 .with_context(|| format!("Could not read file '{}'", app_config_path.display()))?;
359
360 let mut app_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?;
361 let maybe_edge_app = if let Some(app_id) = app_yaml.get("app_id").and_then(|s| s.as_str()) {
362 wasmer_backend_api::query::get_app_by_id(client, app_id.to_owned())
363 .await
364 .ok()
365 } else {
366 None
367 };
368
369 let mut owner = self
370 .get_owner(client, &mut app_yaml, maybe_edge_app.as_ref())
371 .await?;
372 let previous_owner = owner.clone();
373 owner = self.ensure_owner_access(client, owner).await?;
374
375 let mapping = app_yaml
376 .as_mapping_mut()
377 .context("app config must be a mapping")?;
378 mapping.insert("owner".into(), owner.clone().into());
379 if owner != previous_owner {
380 mapping.remove("app_id");
381 mapping.remove("name");
382 }
383
384 if mapping.get("name").is_none()
385 && let Some(app_name) = &self.app_name
386 {
387 mapping.insert("name".into(), app_name.to_string().into());
388 } else if mapping.get("name").is_none()
389 && let Some(maybe_edge_app) = maybe_edge_app.as_ref()
390 {
391 mapping.insert("name".into(), maybe_edge_app.name.to_string().into());
392 } else if mapping.get("name").is_none() {
393 if !self.non_interactive {
394 let default_name = base_dir_path
395 .file_name()
396 .and_then(|f| f.to_str())
397 .map(|s| s.to_owned());
398 let app_name = crate::utils::prompts::prompt_new_app_name(
399 "Enter the name of the app",
400 default_name.as_deref(),
401 &owner,
402 Some(client),
403 )
404 .await?;
405
406 mapping.insert("name".into(), app_name.into());
407 } else {
408 if !self.quiet {
409 eprintln!("The app.yaml does not specify any app name.");
410 eprintln!(
411 "Please, use the --app_name <app_name> to specify the name of the app."
412 );
413 }
414
415 anyhow::bail!(
416 "Cannot proceed with the deployment as the app spec in path {} does not have\n a 'name' field.",
417 app_config_path.display()
418 );
419 }
420 }
421
422 let current_config: AppConfigV1 = serde_yaml::from_value(app_yaml.clone())?;
423 let new_config_raw = crate::utils::yaml::apply_app_config_to_yaml(
424 &config_str,
425 ¤t_config.clone().to_yaml_value()?,
426 )?;
427 std::fs::write(app_config_path, new_config_raw)
428 .with_context(|| format!("Could not write file: '{}'", app_config_path.display()))?;
429
430 let mut app_config = current_config.clone();
431 app_config.owner = Some(owner.clone());
432
433 match &app_config.package {
434 PackageSource::Path(_) => {}
435 other => {
436 anyhow::bail!(
437 "remote deployments require the app's package to reference a local path (found `{other}`)"
438 );
439 }
440 }
441
442 let original_config = Some(app_config.clone().to_yaml_value()?);
443
444 Ok(RemoteBuildInput {
445 app_config,
446 owner,
447 original_config,
448 config_path: Some(app_config_path.to_path_buf()),
449 })
450 }
451
452 async fn prepare_remote_build_without_config(
453 &self,
454 client: &WasmerClient,
455 base_dir_path: &Path,
456 ) -> anyhow::Result<RemoteBuildInput> {
457 let initial_owner = if let Some(owner) = &self.owner {
458 owner.clone()
459 } else if self.non_interactive {
460 anyhow::bail!("No owner specified: use --owner XXX");
461 } else {
462 let user =
463 wasmer_backend_api::query::current_user_with_namespaces(client, None).await?;
464 crate::utils::prompts::prompt_for_namespace(
465 "Who should own this app?",
466 None,
467 Some(&user),
468 )?
469 };
470
471 let owner = self.ensure_owner_access(client, initial_owner).await?;
472
473 let app_name = if let Some(name) = &self.app_name {
474 name.clone()
475 } else if self.non_interactive {
476 anyhow::bail!("Cannot determine app name: use --app_name <app_name>");
477 } else {
478 let default_name = base_dir_path
479 .file_name()
480 .and_then(|f| f.to_str())
481 .map(|s| s.to_owned());
482 crate::utils::prompts::prompt_new_app_name(
483 "Enter the name of the app",
484 default_name.as_deref(),
485 &owner,
486 Some(client),
487 )
488 .await?
489 };
490
491 let app_config = AppConfigV1 {
492 name: Some(app_name.clone()),
493 app_id: None,
494 owner: Some(owner.clone()),
495 package: PackageSource::Path(String::from(".")),
496 domains: None,
497 locality: None,
498 env: IndexMap::new(),
499 cli_args: None,
500 capabilities: None,
501 scheduled_tasks: None,
502 volumes: None,
503 health_checks: None,
504 debug: None,
505 scaling: None,
506 redirect: None,
507 jobs: None,
508 extra: IndexMap::new(),
509 };
510
511 let original_config = Some(app_config.clone().to_yaml_value()?);
512
513 Ok(RemoteBuildInput {
514 app_config,
515 owner,
516 original_config,
517 config_path: None,
518 })
519 }
520
521 async fn ensure_owner_access(
522 &self,
523 client: &WasmerClient,
524 owner: String,
525 ) -> anyhow::Result<String> {
526 if wasmer_backend_api::query::viewer_can_deploy_to_namespace(client, &owner).await? {
527 return Ok(owner);
528 }
529
530 eprintln!("It seems you don't have access to {}", owner.bold());
531 if self.non_interactive {
532 anyhow::bail!(
533 "Please, change the owner before deploying or check your current user with `{} whoami`.",
534 std::env::args().next().unwrap_or("wasmer".into())
535 );
536 }
537
538 let user = wasmer_backend_api::query::current_user_with_namespaces(client, None).await?;
539 let owner = crate::utils::prompts::prompt_for_namespace(
540 "Who should own this app?",
541 None,
542 Some(&user),
543 )?;
544
545 Ok(owner)
546 }
547}
548
549#[async_trait::async_trait]
550impl AsyncCliCommand for CmdAppDeploy {
551 type Output = ();
552
553 async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
554 let client = login_user(&self.env, !self.non_interactive, "deploy an app").await?;
555
556 if self.build_remote && self.publish_package {
557 anyhow::bail!("--build-remote cannot be combined with --publish-package");
558 }
559
560 if self.build_remote {
561 self.handle_remote_build(&client).await?;
562 return Ok(());
563 }
564
565 let (app_config_path, base_dir_path) = self.resolve_app_paths()?;
566
567 if !app_config_path.is_file() && self.ensure_app_config {
568 let owner = if let Some(owner) = &self.owner {
569 owner.clone()
570 } else if self.non_interactive {
571 anyhow::bail!("No owner specified: use --owner <owner>");
572 } else {
573 let user =
574 wasmer_backend_api::query::current_user_with_namespaces(&client, None).await?;
575 crate::utils::prompts::prompt_for_namespace(
576 "Who should own this app?",
577 None,
578 Some(&user),
579 )?
580 };
581
582 let app_name = if let Some(name) = &self.app_name {
583 name.clone()
584 } else if self.non_interactive {
585 anyhow::bail!("No app name specified: use --name <app_name>");
586 } else {
587 let default_name = base_dir_path
588 .file_name()
589 .and_then(|f| f.to_str())
590 .map(|s| s.to_owned());
591 crate::utils::prompts::prompt_new_app_name(
592 "Enter the name of the app",
593 default_name.as_deref(),
594 &owner,
595 Some(&client),
596 )
597 .await?
598 };
599
600 let app_config = minimal_app_config(&owner, &app_name);
601 write_app_config(&app_config, Some(base_dir_path.clone())).await?;
602 }
603
604 if !app_config_path.is_file()
605 || self.template.is_some()
606 || self.package.is_some()
607 || self.use_local_manifest
608 {
609 if !self.non_interactive {
610 return self.create().await;
612 } else {
613 anyhow::bail!(
614 "No app configuration was found in {}. Create an app before deploying or re-run in interactive mode!",
615 app_config_path.display()
616 );
617 }
618 }
619
620 assert!(app_config_path.is_file());
621
622 let config_str = std::fs::read_to_string(&app_config_path)
623 .with_context(|| format!("Could not read file '{}'", &app_config_path.display()))?;
624
625 let mut app_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?;
627 let maybe_edge_app = if let Some(app_id) = app_yaml.get("app_id").and_then(|s| s.as_str()) {
628 wasmer_backend_api::query::get_app_by_id(&client, app_id.to_owned())
629 .await
630 .ok()
631 } else {
632 None
633 };
634
635 let mut owner = self
636 .get_owner(&client, &mut app_yaml, maybe_edge_app.as_ref())
637 .await?;
638
639 if !wasmer_backend_api::query::viewer_can_deploy_to_namespace(&client, &owner).await? {
640 eprintln!("It seems you don't have access to {}", owner.bold());
641 if self.non_interactive {
642 anyhow::bail!(
643 "Please, change the owner before deploying or check your current user with `{} whoami`.",
644 std::env::args().next().unwrap_or("wasmer".into())
645 );
646 } else {
647 let user =
648 wasmer_backend_api::query::current_user_with_namespaces(&client, None).await?;
649 owner = crate::utils::prompts::prompt_for_namespace(
650 "Who should own this app?",
651 None,
652 Some(&user),
653 )?;
654
655 app_yaml
656 .as_mapping_mut()
657 .unwrap()
658 .insert("owner".into(), owner.clone().into());
659
660 if app_yaml.get("app_id").is_some() {
661 app_yaml.as_mapping_mut().unwrap().remove("app_id");
662 }
663
664 if app_yaml.get("name").is_some() {
665 app_yaml.as_mapping_mut().unwrap().remove("name");
666 }
667 }
668 }
669
670 if app_yaml.get("name").is_none()
671 && let Some(app_name) = &self.app_name
672 {
673 app_yaml
674 .as_mapping_mut()
675 .unwrap()
676 .insert("name".into(), app_name.to_string().into());
677 } else if app_yaml.get("name").is_none()
678 && let Some(maybe_edge_app) = maybe_edge_app.as_ref()
679 {
680 app_yaml
681 .as_mapping_mut()
682 .unwrap()
683 .insert("name".into(), maybe_edge_app.name.to_string().into());
684 } else if app_yaml.get("name").is_none() {
685 if !self.non_interactive {
686 let default_name = std::env::current_dir().ok().and_then(|dir| {
687 dir.file_name()
688 .and_then(|f| f.to_str())
689 .map(|s| s.to_owned())
690 });
691 let app_name = crate::utils::prompts::prompt_new_app_name(
692 "Enter the name of the app",
693 default_name.as_deref(),
694 &owner,
695 self.env.client().ok().as_ref(),
696 )
697 .await?;
698
699 app_yaml
700 .as_mapping_mut()
701 .unwrap()
702 .insert("name".into(), app_name.into());
703 } else {
704 if !self.quiet {
705 eprintln!("The app.yaml does not specify any app name.");
706 eprintln!(
707 "Please, use the --app_name <app_name> to specify the name of the app."
708 );
709 }
710
711 anyhow::bail!(
712 "Cannot proceed with the deployment as the app spec in path {} does not have
713 a 'name' field.",
714 app_config_path.display()
715 )
716 }
717 }
718
719 let original_app_config: AppConfigV1 = serde_yaml::from_value(app_yaml.clone())?;
720 let new_config_raw = crate::utils::yaml::apply_app_config_to_yaml(
721 &config_str,
722 &original_app_config.clone().to_yaml_value()?,
723 )?;
724 std::fs::write(&app_config_path, new_config_raw)
725 .with_context(|| format!("Could not write file: '{}'", app_config_path.display()))?;
726
727 let mut app_config = original_app_config.clone();
728
729 app_config.owner = Some(owner.clone());
730
731 let wait = if self.no_wait {
732 WaitMode::Deployed
733 } else {
734 WaitMode::Reachable
735 };
736
737 let mut app_cfg_new = app_config.clone();
738
739 if !self.build_remote {
742 let is_local_pkg = app_cfg_new.package.to_string() == ".";
743 let manifest_path = base_dir_path.join(DEFAULT_PACKAGE_MANIFEST_FILE);
744 let manifest_exists = manifest_path.is_file();
745
746 if is_local_pkg && !manifest_exists {
747 if self.non_interactive {
748 anyhow::bail!(
749 "The app.yaml references a local package, but no wasmer.toml manifest was found in {} - use --build-remote to deploy with a remote build.",
750 base_dir_path.display()
751 );
752 }
753
754 let theme = ColorfulTheme::default();
755 let should_use_remote = Confirm::with_theme(&theme)
756 .with_prompt(format!(
757 "No wasmer.toml manifest found in {}. Deploy with a remote build instead?",
758 base_dir_path.display()
759 ))
760 .default(true)
761 .interact()?;
762
763 if should_use_remote {
764 self.handle_remote_build(&client).await?;
765 return Ok(());
766 } else {
767 anyhow::bail!(
768 "The app.yaml references a local package, but no wasmer.toml manifest was found in {}",
769 base_dir_path.display()
770 );
771 }
772 }
773 }
774
775 let opts = match &app_cfg_new.package {
776 PackageSource::Path(path) => {
777 let path = PathBuf::from(path);
778
779 let path = if path.is_absolute() {
780 path
781 } else {
782 app_config_path.parent().unwrap().join(path)
783 };
784
785 if !self.quiet {
786 eprintln!("Loading local package (manifest path: {})", path.display());
787 }
788
789 let package_id = self.publish(&client, owner.clone(), path).await?;
790
791 app_cfg_new.package = package_id.into();
792
793 DeployAppOpts {
794 app: &app_cfg_new,
795 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
796 allow_create: true,
797 make_default: !self.no_default,
798 owner: Some(owner),
799 wait,
800 }
801 }
802 PackageSource::Ident(PackageIdent::Named(n)) => {
803 if let Ok(Some((manifest_path, manifest))) = load_package_manifest(&base_dir_path) {
810 if let Some(package) = &manifest.package {
811 if let Some(name) = &package.name {
812 if name == &n.full_name() {
813 if !self.quiet {
814 eprintln!(
815 "Found local package (manifest path: {}).",
816 manifest_path.display()
817 );
818 eprintln!(
819 "The `package` field in `app.yaml` specified the same named package ({name})."
820 );
821 eprintln!("This behaviour is deprecated.");
822 }
823
824 let theme = dialoguer::theme::ColorfulTheme::default();
825 if self.non_interactive {
826 if !self.quiet {
827 eprintln!(
828 "Hint: replace `package: {n}` with `package: .` to replicate the intended behaviour."
829 );
830 }
831 anyhow::bail!("deprecated deploy behaviour")
832 } else if Confirm::with_theme(&theme)
833 .with_prompt("Change package to '.' in app.yaml?")
834 .interact()?
835 {
836 app_config.package = PackageSource::Path(String::from("."));
837 let new_config_raw =
839 crate::utils::yaml::apply_app_config_to_yaml(
840 &config_str,
841 &app_config.clone().to_yaml_value()?,
842 )?;
843 std::fs::write(&app_config_path, new_config_raw).with_context(
844 || {
845 format!(
846 "Could not write file: '{}'",
847 app_config_path.display()
848 )
849 },
850 )?;
851
852 log::info!(
853 "Using package {} ({})",
854 app_config.package,
855 n.full_name()
856 );
857
858 let package_id =
859 self.publish(&client, owner.clone(), manifest_path).await?;
860
861 app_config.package = package_id.into();
862
863 DeployAppOpts {
864 app: &app_config,
865 original_config: Some(
866 app_config.clone().to_yaml_value().unwrap(),
867 ),
868 allow_create: true,
869 make_default: !self.no_default,
870 owner: Some(owner),
871 wait,
872 }
873 } else {
874 if !self.quiet {
875 eprintln!(
876 "{}: the package will not be published and the deployment will fail if the package does not already exist.",
877 "Warning".yellow().bold()
878 );
879 }
880 DeployAppOpts {
881 app: &app_config,
882 original_config: Some(
883 app_config.clone().to_yaml_value().unwrap(),
884 ),
885 allow_create: true,
886 make_default: !self.no_default,
887 owner: Some(owner),
888 wait,
889 }
890 }
891 } else {
892 DeployAppOpts {
893 app: &app_config,
894 original_config: Some(
895 app_config.clone().to_yaml_value().unwrap(),
896 ),
897 allow_create: true,
898 make_default: !self.no_default,
899 owner: Some(owner),
900 wait,
901 }
902 }
903 } else {
904 DeployAppOpts {
905 app: &app_config,
906 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
907 allow_create: true,
908 make_default: !self.no_default,
909 owner: Some(owner),
910 wait,
911 }
912 }
913 } else {
914 DeployAppOpts {
915 app: &app_config,
916 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
917 allow_create: true,
918 make_default: !self.no_default,
919 owner: Some(owner),
920 wait,
921 }
922 }
923 } else {
924 log::info!("Using package {}", app_config.package);
925 DeployAppOpts {
926 app: &app_config,
927 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
928 allow_create: true,
929 make_default: !self.no_default,
930 owner: Some(owner),
931 wait,
932 }
933 }
934 }
935 _ => {
936 log::info!("Using package {}", app_config.package);
937 DeployAppOpts {
938 app: &app_config,
939 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
940 allow_create: true,
941 make_default: !self.no_default,
942 owner: Some(owner),
943 wait,
944 }
945 }
946 };
947
948 let owner = &opts.owner.clone().or_else(|| opts.app.owner.clone());
949 let app = &opts.app;
950
951 let pretty_name = if let Some(owner) = &owner {
952 format!(
953 "{} ({})",
954 app.name
955 .as_ref()
956 .context("App name has to be specified")?
957 .bold(),
958 owner.bold()
959 )
960 } else {
961 app.name
962 .as_ref()
963 .context("App name has to be specified")?
964 .bold()
965 .to_string()
966 };
967
968 if !self.quiet {
969 eprintln!("\nDeploying app {pretty_name} to Wasmer Edge...\n");
970 }
971
972 let app_version = deploy_app(&client, opts.clone()).await?;
973
974 let mut new_app_config = app_config_from_api(&app_version)?;
975
976 if self.no_persist_id {
977 new_app_config.app_id = None;
978 }
979
980 new_app_config.package = app_config.package.clone();
982 if new_app_config != app_config {
986 let new_merged = crate::utils::merge_yaml_values(
990 &app_config.clone().to_yaml_value()?,
991 &new_app_config.to_yaml_value()?,
992 );
993 let new_config_raw =
994 crate::utils::yaml::apply_app_config_to_yaml_file(&app_config_path, &new_merged)?;
995 std::fs::write(&app_config_path, new_config_raw).with_context(|| {
996 format!("Could not write file: '{}'", app_config_path.display())
997 })?;
998 }
999
1000 wait_app(&client, opts.clone(), app_version.clone(), self.quiet).await?;
1001
1002 if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) {
1003 println!("{}", serde_json::to_string_pretty(&app_version)?);
1004 }
1005
1006 Ok(())
1007 }
1008}
1009
1010#[derive(Debug, Clone)]
1011pub struct DeployAppOpts<'a> {
1012 pub app: &'a AppConfigV1,
1013 pub original_config: Option<serde_yaml::value::Value>,
1017 #[allow(dead_code)]
1018 pub allow_create: bool,
1019 pub make_default: bool,
1020 pub owner: Option<String>,
1021 pub wait: WaitMode,
1022}
1023
1024fn remote_progress_handler(quiet: bool) -> impl FnMut(DeployRemoteEvent) {
1025 move |event| {
1026 if quiet {
1027 return;
1028 }
1029
1030 match event {
1031 DeployRemoteEvent::CreatingArchive { path } => {
1032 eprintln!("Creating deployment archive from {}...", path.display());
1033 }
1034 DeployRemoteEvent::ArchiveCreated {
1035 file_count,
1036 archive_size,
1037 } => {
1038 eprintln!(
1039 "Packaging project directory ({} files, {})",
1040 file_count,
1041 ByteSize(archive_size)
1042 );
1043 }
1044 DeployRemoteEvent::GeneratingUploadUrl => {
1045 eprintln!("Requesting upload target...");
1046 }
1047 DeployRemoteEvent::UploadArchiveStart { archive_size } => {
1048 eprintln!(
1049 "Uploading archive ({} bytes) to Wasmer...",
1050 ByteSize(archive_size)
1051 );
1052 }
1053 DeployRemoteEvent::DeterminingBuildConfiguration => {
1054 eprintln!("Determining build configuration...");
1055 }
1056 DeployRemoteEvent::BuildConfigDetermined { config } => {
1057 eprintln!(
1058 "Build configuration determined (preset: {})",
1059 config.preset_name
1060 );
1061 }
1062 DeployRemoteEvent::InitiatingBuild { .. } => {
1063 eprintln!("Requesting remote build...");
1064 }
1065 DeployRemoteEvent::StreamingAutobuildLogs { build_id } => {
1066 eprintln!("Streaming build logs (build id: {build_id})");
1067 }
1068 DeployRemoteEvent::AutobuildLog { log } => {
1069 let kind = log.kind;
1070 let datetime = format_autobuild_datetime(&log.datetime);
1071 let message = log.message;
1072
1073 if let Some(msg) = message {
1074 eprintln!("{} {}", datetime.dimmed(), msg);
1075 } else if matches!(kind, AutoBuildDeployAppLogKind::Complete) {
1076 eprintln!("Streaming build logs complete");
1077 }
1078 }
1079 DeployRemoteEvent::Finished => {
1080 eprintln!("Remote build finished successfully.\n");
1081 }
1082 _ => {
1083 eprintln!("Unknown event: {event:?}");
1084 }
1085 }
1086 }
1087}
1088
1089pub async fn deploy_app(
1090 client: &WasmerClient,
1091 opts: DeployAppOpts<'_>,
1092) -> Result<DeployAppVersion, anyhow::Error> {
1093 let app = opts.app;
1094
1095 let config_value = app.clone().to_yaml_value()?;
1096 let final_config = if let Some(old) = &opts.original_config {
1097 crate::utils::merge_yaml_values(old, &config_value)
1098 } else {
1099 config_value
1100 };
1101 let mut raw_config = serde_yaml::to_string(&final_config)?.trim().to_string();
1102 raw_config.push('\n');
1103
1104 let version = wasmer_backend_api::query::publish_deploy_app(
1107 client,
1108 wasmer_backend_api::types::PublishDeployAppVars {
1109 config: raw_config,
1110 name: app.name.clone().context("Expected an app name")?.into(),
1111 owner: opts.owner.map(|o| o.into()),
1112 make_default: Some(opts.make_default),
1113 },
1114 )
1115 .await
1116 .context("could not create app in the backend")?;
1117
1118 Ok(version)
1119}
1120
1121#[derive(Debug, PartialEq, Eq, Copy, Clone)]
1122pub enum WaitMode {
1123 Deployed,
1125 Reachable,
1127}
1128
1129pub async fn wait_app(
1131 client: &WasmerClient,
1132 opts: DeployAppOpts<'_>,
1133 version: DeployAppVersion,
1134 quiet: bool,
1135) -> Result<(DeployApp, DeployAppVersion), anyhow::Error> {
1136 let wait = opts.wait;
1137 let make_default = opts.make_default;
1138
1139 let app_id = version
1140 .app
1141 .as_ref()
1142 .context("app field on app version is empty")?
1143 .id
1144 .inner()
1145 .to_string();
1146
1147 let app = wasmer_backend_api::query::get_app_by_id(client, app_id.clone())
1148 .await
1149 .context("could not fetch app from backend")?;
1150
1151 if !quiet {
1152 eprintln!(
1153 "{}",
1154 format!(
1155 "{} App {} ({}) deployed successfully.",
1156 "✔".green(),
1157 app.name,
1158 app.owner.global_name,
1159 )
1160 .bold()
1161 );
1162 eprintln!();
1163 eprintln!("Live: {}", app.url.blue().bold().underline());
1164 eprintln!("Manage: {}", app.admin_url);
1165
1166 if let Some(banner) = build_perish_banner(&app) {
1167 eprintln!("\n{}", banner.yellow().bold());
1168 }
1169 }
1170
1171 match wait {
1172 WaitMode::Deployed => {}
1173 WaitMode::Reachable => {
1174 if !quiet {
1175 eprintln!();
1176 eprintln!("Waiting for new deployment to become available...");
1177 eprintln!("(You can safely stop waiting now with CTRL-C)");
1178 }
1179
1180 let stderr = std::io::stderr();
1181
1182 tokio::time::sleep(Duration::from_secs(2)).await;
1183
1184 let start = tokio::time::Instant::now();
1185 let client = reqwest::Client::builder()
1186 .connect_timeout(Duration::from_secs(10))
1187 .timeout(Duration::from_secs(90))
1188 .redirect(reqwest::redirect::Policy::none())
1190 .build()
1191 .unwrap();
1192
1193 let check_url = if make_default { &app.url } else { &version.url };
1194
1195 let mut sleep_millis: u64 = 1_000;
1196 loop {
1197 let total_elapsed = start.elapsed();
1198 if total_elapsed > Duration::from_secs(60 * 5) {
1199 if !quiet {
1200 eprintln!();
1201 }
1202 anyhow::bail!("\nApp still not reachable after 5 minutes...");
1203 }
1204
1205 {
1206 let mut lock = stderr.lock();
1207
1208 if !quiet {
1209 write!(&mut lock, ".").unwrap();
1210 }
1211 lock.flush().unwrap();
1212 }
1213
1214 let request_start = tokio::time::Instant::now();
1215
1216 tracing::debug!(%check_url, "checking health of app");
1217 match client.get(check_url).send().await {
1218 Ok(res) => {
1219 let header = res
1220 .headers()
1221 .get(&EDGE_HEADER_APP_VERSION_ID)
1222 .and_then(|x| x.to_str().ok())
1223 .unwrap_or_default();
1224
1225 tracing::debug!(
1226 %check_url,
1227 status=res.status().as_u16(),
1228 app_version_header=%header,
1229 "app request response received",
1230 );
1231
1232 if header == version.id.inner() {
1233 if !quiet {
1234 eprintln!();
1235 }
1236 if !(res.status().is_success() || res.status().is_redirection()) {
1237 eprintln!(
1238 "{}",
1239 format!(
1240 "The app version was deployed correctly, but fails with a non-success status code of {}",
1241 res.status()).yellow()
1242 );
1243 } else {
1244 eprintln!("{} Deployment complete", "𖥔".yellow().bold());
1245 }
1246
1247 break;
1248 }
1249
1250 tracing::debug!(
1251 current=%header,
1252 expected=%version.id.inner(),
1253 "app is not at the right version yet",
1254 );
1255 }
1256 Err(err) => {
1257 tracing::debug!(?err, "health check request failed");
1258 }
1259 };
1260
1261 let elapsed: u64 = request_start
1264 .elapsed()
1265 .as_millis()
1266 .try_into()
1267 .unwrap_or_default();
1268 let to_sleep = Duration::from_millis(sleep_millis.saturating_sub(elapsed));
1269 tokio::time::sleep(to_sleep).await;
1270 sleep_millis = (sleep_millis * 2).max(10_000);
1271 }
1272 }
1273 }
1274
1275 Ok((app, version))
1276}
1277
1278fn build_perish_banner(app: &DeployApp) -> Option<String> {
1279 let perish_reason = app.perish_reason?;
1280 let will_perish_at = app.will_perish_at.as_ref()?;
1281 let time_left = format_time_left(will_perish_at)?;
1282 let mut banner = format!("⚠️ Your site will be live for {time_left}.");
1283
1284 if let Some(link) = perish_reason_link(perish_reason, app.id.inner()) {
1285 banner.push('\n');
1286 banner.push_str(&link);
1287 }
1288
1289 let mut table = Table::new();
1290 table.load_preset(UTF8_FULL);
1291 table.set_content_arrangement(ContentArrangement::Dynamic);
1292 table.add_row(vec![banner]);
1293
1294 Some(table.to_string())
1295}
1296
1297fn format_time_left(will_perish_at: &wasmer_backend_api::types::DateTime) -> Option<String> {
1298 let will_perish_at = OffsetDateTime::try_from(will_perish_at.clone()).ok()?;
1299 let now = OffsetDateTime::now_utc();
1300 let remaining = will_perish_at - now;
1301 let remaining = if remaining.is_negative() {
1302 TimeDuration::ZERO
1303 } else {
1304 remaining
1305 };
1306
1307 Some(format_duration_words(remaining))
1308}
1309
1310fn format_autobuild_datetime(datetime: &wasmer_backend_api::types::DateTime) -> String {
1311 let format = format_description::parse_borrowed::<1>(
1312 "[month repr:short] [day padding:none] [hour]:[minute]:[second].[subsecond digits:3]",
1313 );
1314 let Ok(format) = format else {
1315 return datetime.0.clone();
1316 };
1317
1318 OffsetDateTime::try_from(datetime.clone())
1319 .ok()
1320 .and_then(|value| value.format(&format).ok())
1321 .unwrap_or_else(|| datetime.0.clone())
1322}
1323
1324fn format_duration_words(duration: TimeDuration) -> String {
1325 if duration >= TimeDuration::DAY {
1326 let days = duration.whole_days();
1327 format!("{days} day{}", if days == 1 { "" } else { "s" })
1328 } else if duration >= TimeDuration::HOUR {
1329 let hours = duration.whole_hours();
1330 format!("{hours} hour{}", if hours == 1 { "" } else { "s" })
1331 } else if duration >= TimeDuration::MINUTE {
1332 let minutes = duration.whole_minutes();
1333 format!("{minutes} minute{}", if minutes == 1 { "" } else { "s" })
1334 } else {
1335 let seconds = duration.whole_seconds();
1336 format!("{seconds} second{}", if seconds == 1 { "" } else { "s" })
1337 }
1338}
1339
1340fn perish_reason_link(
1341 perish_reason: DeployDeployAppPerishReasonChoices,
1342 app_id: &str,
1343) -> Option<String> {
1344 match perish_reason {
1345 DeployDeployAppPerishReasonChoices::AppUnclaimed => Some(format!(
1346 "Claim it to keep it online: https://wasmer.io/apps/claim/{app_id}"
1347 )),
1348 DeployDeployAppPerishReasonChoices::UserPendingVerification => {
1349 Some("Verify now to keep it online: https://wasmer.io/verify".to_string())
1350 }
1351 DeployDeployAppPerishReasonChoices::UserRequested => None,
1352 }
1353}
1354
1355pub fn app_config_from_api(version: &DeployAppVersion) -> Result<AppConfigV1, anyhow::Error> {
1356 let app_id = version
1357 .app
1358 .as_ref()
1359 .context("app field on app version is empty")?
1360 .id
1361 .inner()
1362 .to_string();
1363
1364 let cfg = &version.user_yaml_config;
1365 let mut cfg = AppConfigV1::parse_yaml(cfg)
1366 .context("could not parse app config from backend app version")?;
1367
1368 cfg.app_id = Some(app_id);
1369 Ok(cfg)
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::format_duration_words;
1375 use time::Duration as TimeDuration;
1376
1377 #[test]
1378 fn format_duration_words_seconds() {
1379 assert_eq!(format_duration_words(TimeDuration::ZERO), "0 seconds");
1380 assert_eq!(format_duration_words(TimeDuration::seconds(1)), "1 second");
1381 assert_eq!(
1382 format_duration_words(TimeDuration::seconds(59)),
1383 "59 seconds"
1384 );
1385 }
1386
1387 #[test]
1388 fn format_duration_words_minutes() {
1389 assert_eq!(format_duration_words(TimeDuration::seconds(60)), "1 minute");
1390 assert_eq!(format_duration_words(TimeDuration::seconds(61)), "1 minute");
1391 assert_eq!(format_duration_words(TimeDuration::minutes(2)), "2 minutes");
1392 }
1393
1394 #[test]
1395 fn format_duration_words_hours() {
1396 assert_eq!(format_duration_words(TimeDuration::minutes(60)), "1 hour");
1397 assert_eq!(format_duration_words(TimeDuration::minutes(119)), "1 hour");
1398 assert_eq!(format_duration_words(TimeDuration::hours(5)), "5 hours");
1399 }
1400
1401 #[test]
1402 fn format_duration_words_days() {
1403 assert_eq!(format_duration_words(TimeDuration::hours(24)), "1 day");
1404 assert_eq!(format_duration_words(TimeDuration::hours(47)), "1 day");
1405 assert_eq!(format_duration_words(TimeDuration::days(3)), "3 days");
1406 assert_eq!(
1407 format_duration_words(TimeDuration::days(4) - TimeDuration::SECOND),
1408 "3 days"
1409 );
1410 }
1411}