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 = serde_yaml::to_string(&new_merged)?;
336 std::fs::write(&path, new_config_raw)
337 .with_context(|| format!("Could not write file: '{}'", path.display()))?;
338 }
339 }
340
341 wait_app(client, opts.clone(), app_version.clone(), self.quiet).await?;
342
343 if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) {
344 println!("{}", serde_json::to_string_pretty(&app_version)?);
345 }
346
347 Ok(())
348 }
349
350 async fn prepare_remote_build_from_file(
351 &self,
352 client: &WasmerClient,
353 app_config_path: &Path,
354 base_dir_path: &Path,
355 ) -> anyhow::Result<RemoteBuildInput> {
356 let config_str = std::fs::read_to_string(app_config_path)
357 .with_context(|| format!("Could not read file '{}'", app_config_path.display()))?;
358
359 let mut app_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?;
360 let maybe_edge_app = if let Some(app_id) = app_yaml.get("app_id").and_then(|s| s.as_str()) {
361 wasmer_backend_api::query::get_app_by_id(client, app_id.to_owned())
362 .await
363 .ok()
364 } else {
365 None
366 };
367
368 let mut owner = self
369 .get_owner(client, &mut app_yaml, maybe_edge_app.as_ref())
370 .await?;
371 let previous_owner = owner.clone();
372 owner = self.ensure_owner_access(client, owner).await?;
373
374 let mapping = app_yaml
375 .as_mapping_mut()
376 .context("app config must be a mapping")?;
377 mapping.insert("owner".into(), owner.clone().into());
378 if owner != previous_owner {
379 mapping.remove("app_id");
380 mapping.remove("name");
381 }
382
383 if mapping.get("name").is_none() && self.app_name.is_some() {
384 mapping.insert(
385 "name".into(),
386 self.app_name.as_ref().unwrap().to_string().into(),
387 );
388 } else if mapping.get("name").is_none() && maybe_edge_app.is_some() {
389 mapping.insert(
390 "name".into(),
391 maybe_edge_app.as_ref().unwrap().name.to_string().into(),
392 );
393 } else if mapping.get("name").is_none() {
394 if !self.non_interactive {
395 let default_name = base_dir_path
396 .file_name()
397 .and_then(|f| f.to_str())
398 .map(|s| s.to_owned());
399 let app_name = crate::utils::prompts::prompt_new_app_name(
400 "Enter the name of the app",
401 default_name.as_deref(),
402 &owner,
403 Some(client),
404 )
405 .await?;
406
407 mapping.insert("name".into(), app_name.into());
408 } else {
409 if !self.quiet {
410 eprintln!("The app.yaml does not specify any app name.");
411 eprintln!(
412 "Please, use the --app_name <app_name> to specify the name of the app."
413 );
414 }
415
416 anyhow::bail!(
417 "Cannot proceed with the deployment as the app spec in path {} does not have\n a 'name' field.",
418 app_config_path.display()
419 );
420 }
421 }
422
423 let current_config: AppConfigV1 = serde_yaml::from_value(app_yaml.clone())?;
424 std::fs::write(app_config_path, serde_yaml::to_string(¤t_config)?)
425 .with_context(|| format!("Could not write file: '{}'", app_config_path.display()))?;
426
427 let mut app_config = current_config.clone();
428 app_config.owner = Some(owner.clone());
429
430 match &app_config.package {
431 PackageSource::Path(_) => {}
432 other => {
433 anyhow::bail!(
434 "remote deployments require the app's package to reference a local path (found `{other}`)"
435 );
436 }
437 }
438
439 let original_config = Some(app_config.clone().to_yaml_value()?);
440
441 Ok(RemoteBuildInput {
442 app_config,
443 owner,
444 original_config,
445 config_path: Some(app_config_path.to_path_buf()),
446 })
447 }
448
449 async fn prepare_remote_build_without_config(
450 &self,
451 client: &WasmerClient,
452 base_dir_path: &Path,
453 ) -> anyhow::Result<RemoteBuildInput> {
454 let initial_owner = if let Some(owner) = &self.owner {
455 owner.clone()
456 } else if self.non_interactive {
457 anyhow::bail!("No owner specified: use --owner XXX");
458 } else {
459 let user =
460 wasmer_backend_api::query::current_user_with_namespaces(client, None).await?;
461 crate::utils::prompts::prompt_for_namespace(
462 "Who should own this app?",
463 None,
464 Some(&user),
465 )?
466 };
467
468 let owner = self.ensure_owner_access(client, initial_owner).await?;
469
470 let app_name = if let Some(name) = &self.app_name {
471 name.clone()
472 } else if self.non_interactive {
473 anyhow::bail!("Cannot determine app name: use --app_name <app_name>");
474 } else {
475 let default_name = base_dir_path
476 .file_name()
477 .and_then(|f| f.to_str())
478 .map(|s| s.to_owned());
479 crate::utils::prompts::prompt_new_app_name(
480 "Enter the name of the app",
481 default_name.as_deref(),
482 &owner,
483 Some(client),
484 )
485 .await?
486 };
487
488 let app_config = AppConfigV1 {
489 name: Some(app_name.clone()),
490 app_id: None,
491 owner: Some(owner.clone()),
492 package: PackageSource::Path(String::from(".")),
493 domains: None,
494 locality: None,
495 env: IndexMap::new(),
496 cli_args: None,
497 capabilities: None,
498 scheduled_tasks: None,
499 volumes: None,
500 health_checks: None,
501 debug: None,
502 scaling: None,
503 redirect: None,
504 jobs: None,
505 extra: IndexMap::new(),
506 };
507
508 let original_config = Some(app_config.clone().to_yaml_value()?);
509
510 Ok(RemoteBuildInput {
511 app_config,
512 owner,
513 original_config,
514 config_path: None,
515 })
516 }
517
518 async fn ensure_owner_access(
519 &self,
520 client: &WasmerClient,
521 owner: String,
522 ) -> anyhow::Result<String> {
523 if wasmer_backend_api::query::viewer_can_deploy_to_namespace(client, &owner).await? {
524 return Ok(owner);
525 }
526
527 eprintln!("It seems you don't have access to {}", owner.bold());
528 if self.non_interactive {
529 anyhow::bail!(
530 "Please, change the owner before deploying or check your current user with `{} whoami`.",
531 std::env::args().next().unwrap_or("wasmer".into())
532 );
533 }
534
535 let user = wasmer_backend_api::query::current_user_with_namespaces(client, None).await?;
536 let owner = crate::utils::prompts::prompt_for_namespace(
537 "Who should own this app?",
538 None,
539 Some(&user),
540 )?;
541
542 Ok(owner)
543 }
544}
545
546#[async_trait::async_trait]
547impl AsyncCliCommand for CmdAppDeploy {
548 type Output = ();
549
550 async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
551 let client = login_user(&self.env, !self.non_interactive, "deploy an app").await?;
552
553 if self.build_remote && self.publish_package {
554 anyhow::bail!("--build-remote cannot be combined with --publish-package");
555 }
556
557 if self.build_remote {
558 self.handle_remote_build(&client).await?;
559 return Ok(());
560 }
561
562 let (app_config_path, base_dir_path) = self.resolve_app_paths()?;
563
564 if !app_config_path.is_file() && self.ensure_app_config {
565 let owner = if let Some(owner) = &self.owner {
566 owner.clone()
567 } else if self.non_interactive {
568 anyhow::bail!("No owner specified: use --owner <owner>");
569 } else {
570 let user =
571 wasmer_backend_api::query::current_user_with_namespaces(&client, None).await?;
572 crate::utils::prompts::prompt_for_namespace(
573 "Who should own this app?",
574 None,
575 Some(&user),
576 )?
577 };
578
579 let app_name = if let Some(name) = &self.app_name {
580 name.clone()
581 } else if self.non_interactive {
582 anyhow::bail!("No app name specified: use --name <app_name>");
583 } else {
584 let default_name = base_dir_path
585 .file_name()
586 .and_then(|f| f.to_str())
587 .map(|s| s.to_owned());
588 crate::utils::prompts::prompt_new_app_name(
589 "Enter the name of the app",
590 default_name.as_deref(),
591 &owner,
592 Some(&client),
593 )
594 .await?
595 };
596
597 let app_config = minimal_app_config(&owner, &app_name);
598 write_app_config(&app_config, Some(base_dir_path.clone())).await?;
599 }
600
601 if !app_config_path.is_file()
602 || self.template.is_some()
603 || self.package.is_some()
604 || self.use_local_manifest
605 {
606 if !self.non_interactive {
607 return self.create().await;
609 } else {
610 anyhow::bail!(
611 "No app configuration was found in {}. Create an app before deploying or re-run in interactive mode!",
612 app_config_path.display()
613 );
614 }
615 }
616
617 assert!(app_config_path.is_file());
618
619 let config_str = std::fs::read_to_string(&app_config_path)
620 .with_context(|| format!("Could not read file '{}'", &app_config_path.display()))?;
621
622 let mut app_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?;
624 let maybe_edge_app = if let Some(app_id) = app_yaml.get("app_id").and_then(|s| s.as_str()) {
625 wasmer_backend_api::query::get_app_by_id(&client, app_id.to_owned())
626 .await
627 .ok()
628 } else {
629 None
630 };
631
632 let mut owner = self
633 .get_owner(&client, &mut app_yaml, maybe_edge_app.as_ref())
634 .await?;
635
636 if !wasmer_backend_api::query::viewer_can_deploy_to_namespace(&client, &owner).await? {
637 eprintln!("It seems you don't have access to {}", owner.bold());
638 if self.non_interactive {
639 anyhow::bail!(
640 "Please, change the owner before deploying or check your current user with `{} whoami`.",
641 std::env::args().next().unwrap_or("wasmer".into())
642 );
643 } else {
644 let user =
645 wasmer_backend_api::query::current_user_with_namespaces(&client, None).await?;
646 owner = crate::utils::prompts::prompt_for_namespace(
647 "Who should own this app?",
648 None,
649 Some(&user),
650 )?;
651
652 app_yaml
653 .as_mapping_mut()
654 .unwrap()
655 .insert("owner".into(), owner.clone().into());
656
657 if app_yaml.get("app_id").is_some() {
658 app_yaml.as_mapping_mut().unwrap().remove("app_id");
659 }
660
661 if app_yaml.get("name").is_some() {
662 app_yaml.as_mapping_mut().unwrap().remove("name");
663 }
664 }
665 }
666
667 if app_yaml.get("name").is_none() && self.app_name.is_some() {
668 app_yaml.as_mapping_mut().unwrap().insert(
669 "name".into(),
670 self.app_name.as_ref().unwrap().to_string().into(),
671 );
672 } else if app_yaml.get("name").is_none() && maybe_edge_app.is_some() {
673 app_yaml.as_mapping_mut().unwrap().insert(
674 "name".into(),
675 maybe_edge_app
676 .as_ref()
677 .map(|v| v.name.to_string())
678 .unwrap()
679 .into(),
680 );
681 } else if app_yaml.get("name").is_none() {
682 if !self.non_interactive {
683 let default_name = std::env::current_dir().ok().and_then(|dir| {
684 dir.file_name()
685 .and_then(|f| f.to_str())
686 .map(|s| s.to_owned())
687 });
688 let app_name = crate::utils::prompts::prompt_new_app_name(
689 "Enter the name of the app",
690 default_name.as_deref(),
691 &owner,
692 self.env.client().ok().as_ref(),
693 )
694 .await?;
695
696 app_yaml
697 .as_mapping_mut()
698 .unwrap()
699 .insert("name".into(), app_name.into());
700 } else {
701 if !self.quiet {
702 eprintln!("The app.yaml does not specify any app name.");
703 eprintln!(
704 "Please, use the --app_name <app_name> to specify the name of the app."
705 );
706 }
707
708 anyhow::bail!(
709 "Cannot proceed with the deployment as the app spec in path {} does not have
710 a 'name' field.",
711 app_config_path.display()
712 )
713 }
714 }
715
716 let original_app_config: AppConfigV1 = serde_yaml::from_value(app_yaml.clone())?;
717 std::fs::write(
718 &app_config_path,
719 serde_yaml::to_string(&original_app_config)?,
720 )
721 .with_context(|| format!("Could not write file: '{}'", app_config_path.display()))?;
722
723 let mut app_config = original_app_config.clone();
724
725 app_config.owner = Some(owner.clone());
726
727 let wait = if self.no_wait {
728 WaitMode::Deployed
729 } else {
730 WaitMode::Reachable
731 };
732
733 let mut app_cfg_new = app_config.clone();
734
735 if !self.build_remote {
738 let is_local_pkg = app_cfg_new.package.to_string() == ".";
739 let manifest_path = base_dir_path.join(DEFAULT_PACKAGE_MANIFEST_FILE);
740 let manifest_exists = manifest_path.is_file();
741
742 if is_local_pkg && !manifest_exists {
743 if self.non_interactive {
744 anyhow::bail!(
745 "The app.yaml references a local package, but no wasmer.toml manifest was found in {} - use --build-remote to deploy with a remote build.",
746 base_dir_path.display()
747 );
748 }
749
750 let theme = ColorfulTheme::default();
751 let should_use_remote = Confirm::with_theme(&theme)
752 .with_prompt(format!(
753 "No wasmer.toml manifest found in {}. Deploy with a remote build instead?",
754 base_dir_path.display()
755 ))
756 .default(true)
757 .interact()?;
758
759 if should_use_remote {
760 self.handle_remote_build(&client).await?;
761 return Ok(());
762 } else {
763 anyhow::bail!(
764 "The app.yaml references a local package, but no wasmer.toml manifest was found in {}",
765 base_dir_path.display()
766 );
767 }
768 }
769 }
770
771 let opts = match &app_cfg_new.package {
772 PackageSource::Path(path) => {
773 let path = PathBuf::from(path);
774
775 let path = if path.is_absolute() {
776 path
777 } else {
778 app_config_path.parent().unwrap().join(path)
779 };
780
781 if !self.quiet {
782 eprintln!("Loading local package (manifest path: {})", path.display());
783 }
784
785 let package_id = self.publish(&client, owner.clone(), path).await?;
786
787 app_cfg_new.package = package_id.into();
788
789 DeployAppOpts {
790 app: &app_cfg_new,
791 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
792 allow_create: true,
793 make_default: !self.no_default,
794 owner: Some(owner),
795 wait,
796 }
797 }
798 PackageSource::Ident(PackageIdent::Named(n)) => {
799 if let Ok(Some((manifest_path, manifest))) = load_package_manifest(&base_dir_path) {
806 if let Some(package) = &manifest.package {
807 if let Some(name) = &package.name {
808 if name == &n.full_name() {
809 if !self.quiet {
810 eprintln!(
811 "Found local package (manifest path: {}).",
812 manifest_path.display()
813 );
814 eprintln!(
815 "The `package` field in `app.yaml` specified the same named package ({name})."
816 );
817 eprintln!("This behaviour is deprecated.");
818 }
819
820 let theme = dialoguer::theme::ColorfulTheme::default();
821 if self.non_interactive {
822 if !self.quiet {
823 eprintln!(
824 "Hint: replace `package: {n}` with `package: .` to replicate the intended behaviour."
825 );
826 }
827 anyhow::bail!("deprecated deploy behaviour")
828 } else if Confirm::with_theme(&theme)
829 .with_prompt("Change package to '.' in app.yaml?")
830 .interact()?
831 {
832 app_config.package = PackageSource::Path(String::from("."));
833 let new_config_raw = serde_yaml::to_string(&app_config)?;
835 std::fs::write(&app_config_path, new_config_raw).with_context(
836 || {
837 format!(
838 "Could not write file: '{}'",
839 app_config_path.display()
840 )
841 },
842 )?;
843
844 log::info!(
845 "Using package {} ({})",
846 app_config.package,
847 n.full_name()
848 );
849
850 let package_id =
851 self.publish(&client, owner.clone(), manifest_path).await?;
852
853 app_config.package = package_id.into();
854
855 DeployAppOpts {
856 app: &app_config,
857 original_config: Some(
858 app_config.clone().to_yaml_value().unwrap(),
859 ),
860 allow_create: true,
861 make_default: !self.no_default,
862 owner: Some(owner),
863 wait,
864 }
865 } else {
866 if !self.quiet {
867 eprintln!(
868 "{}: the package will not be published and the deployment will fail if the package does not already exist.",
869 "Warning".yellow().bold()
870 );
871 }
872 DeployAppOpts {
873 app: &app_config,
874 original_config: Some(
875 app_config.clone().to_yaml_value().unwrap(),
876 ),
877 allow_create: true,
878 make_default: !self.no_default,
879 owner: Some(owner),
880 wait,
881 }
882 }
883 } else {
884 DeployAppOpts {
885 app: &app_config,
886 original_config: Some(
887 app_config.clone().to_yaml_value().unwrap(),
888 ),
889 allow_create: true,
890 make_default: !self.no_default,
891 owner: Some(owner),
892 wait,
893 }
894 }
895 } else {
896 DeployAppOpts {
897 app: &app_config,
898 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
899 allow_create: true,
900 make_default: !self.no_default,
901 owner: Some(owner),
902 wait,
903 }
904 }
905 } else {
906 DeployAppOpts {
907 app: &app_config,
908 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
909 allow_create: true,
910 make_default: !self.no_default,
911 owner: Some(owner),
912 wait,
913 }
914 }
915 } else {
916 log::info!("Using package {}", app_config.package);
917 DeployAppOpts {
918 app: &app_config,
919 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
920 allow_create: true,
921 make_default: !self.no_default,
922 owner: Some(owner),
923 wait,
924 }
925 }
926 }
927 _ => {
928 log::info!("Using package {}", app_config.package);
929 DeployAppOpts {
930 app: &app_config,
931 original_config: Some(app_config.clone().to_yaml_value().unwrap()),
932 allow_create: true,
933 make_default: !self.no_default,
934 owner: Some(owner),
935 wait,
936 }
937 }
938 };
939
940 let owner = &opts.owner.clone().or_else(|| opts.app.owner.clone());
941 let app = &opts.app;
942
943 let pretty_name = if let Some(owner) = &owner {
944 format!(
945 "{} ({})",
946 app.name
947 .as_ref()
948 .context("App name has to be specified")?
949 .bold(),
950 owner.bold()
951 )
952 } else {
953 app.name
954 .as_ref()
955 .context("App name has to be specified")?
956 .bold()
957 .to_string()
958 };
959
960 if !self.quiet {
961 eprintln!("\nDeploying app {pretty_name} to Wasmer Edge...\n");
962 }
963
964 let app_version = deploy_app(&client, opts.clone()).await?;
965
966 let mut new_app_config = app_config_from_api(&app_version)?;
967
968 if self.no_persist_id {
969 new_app_config.app_id = None;
970 }
971
972 new_app_config.package = app_config.package.clone();
974 if new_app_config != app_config {
978 let new_merged = crate::utils::merge_yaml_values(
982 &app_config.clone().to_yaml_value()?,
983 &new_app_config.to_yaml_value()?,
984 );
985 let new_config_raw = serde_yaml::to_string(&new_merged)?;
986 std::fs::write(&app_config_path, new_config_raw).with_context(|| {
987 format!("Could not write file: '{}'", app_config_path.display())
988 })?;
989 }
990
991 wait_app(&client, opts.clone(), app_version.clone(), self.quiet).await?;
992
993 if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) {
994 println!("{}", serde_json::to_string_pretty(&app_version)?);
995 }
996
997 Ok(())
998 }
999}
1000
1001#[derive(Debug, Clone)]
1002pub struct DeployAppOpts<'a> {
1003 pub app: &'a AppConfigV1,
1004 pub original_config: Option<serde_yaml::value::Value>,
1008 #[allow(dead_code)]
1009 pub allow_create: bool,
1010 pub make_default: bool,
1011 pub owner: Option<String>,
1012 pub wait: WaitMode,
1013}
1014
1015fn remote_progress_handler(quiet: bool) -> impl FnMut(DeployRemoteEvent) {
1016 move |event| {
1017 if quiet {
1018 return;
1019 }
1020
1021 match event {
1022 DeployRemoteEvent::CreatingArchive { path } => {
1023 eprintln!("Creating deployment archive from {}...", path.display());
1024 }
1025 DeployRemoteEvent::ArchiveCreated {
1026 file_count,
1027 archive_size,
1028 } => {
1029 eprintln!(
1030 "Packaging project directory ({} files, {})",
1031 file_count,
1032 ByteSize(archive_size)
1033 );
1034 }
1035 DeployRemoteEvent::GeneratingUploadUrl => {
1036 eprintln!("Requesting upload target...");
1037 }
1038 DeployRemoteEvent::UploadArchiveStart { archive_size } => {
1039 eprintln!(
1040 "Uploading archive ({} bytes) to Wasmer...",
1041 ByteSize(archive_size)
1042 );
1043 }
1044 DeployRemoteEvent::DeterminingBuildConfiguration => {
1045 eprintln!("Determining build configuration...");
1046 }
1047 DeployRemoteEvent::BuildConfigDetermined { config } => {
1048 eprintln!(
1049 "Build configuration determined (preset: {})",
1050 config.preset_name
1051 );
1052 }
1053 DeployRemoteEvent::InitiatingBuild { .. } => {
1054 eprintln!("Requesting remote build...");
1055 }
1056 DeployRemoteEvent::StreamingAutobuildLogs { build_id } => {
1057 eprintln!("Streaming build logs (build id: {build_id})");
1058 }
1059 DeployRemoteEvent::AutobuildLog { log } => {
1060 let kind = log.kind;
1061 let datetime = format_autobuild_datetime(&log.datetime);
1062 let message = log.message;
1063
1064 if let Some(msg) = message {
1065 eprintln!("{} {}", datetime.dimmed(), msg);
1066 } else if matches!(kind, AutoBuildDeployAppLogKind::Complete) {
1067 eprintln!("Streaming build logs complete");
1068 }
1069 }
1070 DeployRemoteEvent::Finished => {
1071 eprintln!("Remote build finished successfully.\n");
1072 }
1073 _ => {
1074 eprintln!("Unknown event: {event:?}");
1075 }
1076 }
1077 }
1078}
1079
1080pub async fn deploy_app(
1081 client: &WasmerClient,
1082 opts: DeployAppOpts<'_>,
1083) -> Result<DeployAppVersion, anyhow::Error> {
1084 let app = opts.app;
1085
1086 let config_value = app.clone().to_yaml_value()?;
1087 let final_config = if let Some(old) = &opts.original_config {
1088 crate::utils::merge_yaml_values(old, &config_value)
1089 } else {
1090 config_value
1091 };
1092 let mut raw_config = serde_yaml::to_string(&final_config)?.trim().to_string();
1093 raw_config.push('\n');
1094
1095 let version = wasmer_backend_api::query::publish_deploy_app(
1098 client,
1099 wasmer_backend_api::types::PublishDeployAppVars {
1100 config: raw_config,
1101 name: app.name.clone().context("Expected an app name")?.into(),
1102 owner: opts.owner.map(|o| o.into()),
1103 make_default: Some(opts.make_default),
1104 },
1105 )
1106 .await
1107 .context("could not create app in the backend")?;
1108
1109 Ok(version)
1110}
1111
1112#[derive(Debug, PartialEq, Eq, Copy, Clone)]
1113pub enum WaitMode {
1114 Deployed,
1116 Reachable,
1118}
1119
1120pub async fn wait_app(
1122 client: &WasmerClient,
1123 opts: DeployAppOpts<'_>,
1124 version: DeployAppVersion,
1125 quiet: bool,
1126) -> Result<(DeployApp, DeployAppVersion), anyhow::Error> {
1127 let wait = opts.wait;
1128 let make_default = opts.make_default;
1129
1130 let app_id = version
1131 .app
1132 .as_ref()
1133 .context("app field on app version is empty")?
1134 .id
1135 .inner()
1136 .to_string();
1137
1138 let app = wasmer_backend_api::query::get_app_by_id(client, app_id.clone())
1139 .await
1140 .context("could not fetch app from backend")?;
1141
1142 if !quiet {
1143 eprintln!(
1144 "{}",
1145 format!(
1146 "{} App {} ({}) deployed successfully.",
1147 "✔".green(),
1148 app.name,
1149 app.owner.global_name,
1150 )
1151 .bold()
1152 );
1153 eprintln!();
1154 eprintln!("Live: {}", app.url.blue().bold().underline());
1155 eprintln!("Manage: {}", app.admin_url);
1156
1157 if let Some(banner) = build_perish_banner(&app) {
1158 eprintln!("\n{}", banner.yellow().bold());
1159 }
1160 }
1161
1162 match wait {
1163 WaitMode::Deployed => {}
1164 WaitMode::Reachable => {
1165 if !quiet {
1166 eprintln!();
1167 eprintln!("Waiting for new deployment to become available...");
1168 eprintln!("(You can safely stop waiting now with CTRL-C)");
1169 }
1170
1171 let stderr = std::io::stderr();
1172
1173 tokio::time::sleep(Duration::from_secs(2)).await;
1174
1175 let start = tokio::time::Instant::now();
1176 let client = reqwest::Client::builder()
1177 .connect_timeout(Duration::from_secs(10))
1178 .timeout(Duration::from_secs(90))
1179 .redirect(reqwest::redirect::Policy::none())
1181 .build()
1182 .unwrap();
1183
1184 let check_url = if make_default { &app.url } else { &version.url };
1185
1186 let mut sleep_millis: u64 = 1_000;
1187 loop {
1188 let total_elapsed = start.elapsed();
1189 if total_elapsed > Duration::from_secs(60 * 5) {
1190 if !quiet {
1191 eprintln!();
1192 }
1193 anyhow::bail!("\nApp still not reachable after 5 minutes...");
1194 }
1195
1196 {
1197 let mut lock = stderr.lock();
1198
1199 if !quiet {
1200 write!(&mut lock, ".").unwrap();
1201 }
1202 lock.flush().unwrap();
1203 }
1204
1205 let request_start = tokio::time::Instant::now();
1206
1207 tracing::debug!(%check_url, "checking health of app");
1208 match client.get(check_url).send().await {
1209 Ok(res) => {
1210 let header = res
1211 .headers()
1212 .get(&EDGE_HEADER_APP_VERSION_ID)
1213 .and_then(|x| x.to_str().ok())
1214 .unwrap_or_default();
1215
1216 tracing::debug!(
1217 %check_url,
1218 status=res.status().as_u16(),
1219 app_version_header=%header,
1220 "app request response received",
1221 );
1222
1223 if header == version.id.inner() {
1224 if !quiet {
1225 eprintln!();
1226 }
1227 if !(res.status().is_success() || res.status().is_redirection()) {
1228 eprintln!(
1229 "{}",
1230 format!(
1231 "The app version was deployed correctly, but fails with a non-success status code of {}",
1232 res.status()).yellow()
1233 );
1234 } else {
1235 eprintln!("{} Deployment complete", "𖥔".yellow().bold());
1236 }
1237
1238 break;
1239 }
1240
1241 tracing::debug!(
1242 current=%header,
1243 expected=%version.id.inner(),
1244 "app is not at the right version yet",
1245 );
1246 }
1247 Err(err) => {
1248 tracing::debug!(?err, "health check request failed");
1249 }
1250 };
1251
1252 let elapsed: u64 = request_start
1255 .elapsed()
1256 .as_millis()
1257 .try_into()
1258 .unwrap_or_default();
1259 let to_sleep = Duration::from_millis(sleep_millis.saturating_sub(elapsed));
1260 tokio::time::sleep(to_sleep).await;
1261 sleep_millis = (sleep_millis * 2).max(10_000);
1262 }
1263 }
1264 }
1265
1266 Ok((app, version))
1267}
1268
1269fn build_perish_banner(app: &DeployApp) -> Option<String> {
1270 let perish_reason = app.perish_reason?;
1271 let will_perish_at = app.will_perish_at.as_ref()?;
1272 let time_left = format_time_left(will_perish_at)?;
1273 let mut banner = format!("⚠️ Your site will be live for {time_left}.");
1274
1275 if let Some(link) = perish_reason_link(perish_reason, app.id.inner()) {
1276 banner.push('\n');
1277 banner.push_str(&link);
1278 }
1279
1280 let mut table = Table::new();
1281 table.load_preset(UTF8_FULL);
1282 table.set_content_arrangement(ContentArrangement::Dynamic);
1283 table.add_row(vec![banner]);
1284
1285 Some(table.to_string())
1286}
1287
1288fn format_time_left(will_perish_at: &wasmer_backend_api::types::DateTime) -> Option<String> {
1289 let will_perish_at = OffsetDateTime::try_from(will_perish_at.clone()).ok()?;
1290 let now = OffsetDateTime::now_utc();
1291 let remaining = will_perish_at - now;
1292 let remaining = if remaining.is_negative() {
1293 TimeDuration::ZERO
1294 } else {
1295 remaining
1296 };
1297
1298 Some(format_duration_words(remaining))
1299}
1300
1301fn format_autobuild_datetime(datetime: &wasmer_backend_api::types::DateTime) -> String {
1302 let format = format_description::parse(
1303 "[month repr:short] [day padding:none] [hour]:[minute]:[second].[subsecond digits:3]",
1304 );
1305 let Ok(format) = format else {
1306 return datetime.0.clone();
1307 };
1308
1309 OffsetDateTime::try_from(datetime.clone())
1310 .ok()
1311 .and_then(|value| value.format(&format).ok())
1312 .unwrap_or_else(|| datetime.0.clone())
1313}
1314
1315fn format_duration_words(duration: TimeDuration) -> String {
1316 if duration >= TimeDuration::DAY {
1317 let days = duration.whole_days();
1318 format!("{days} day{}", if days == 1 { "" } else { "s" })
1319 } else if duration >= TimeDuration::HOUR {
1320 let hours = duration.whole_hours();
1321 format!("{hours} hour{}", if hours == 1 { "" } else { "s" })
1322 } else if duration >= TimeDuration::MINUTE {
1323 let minutes = duration.whole_minutes();
1324 format!("{minutes} minute{}", if minutes == 1 { "" } else { "s" })
1325 } else {
1326 let seconds = duration.whole_seconds();
1327 format!("{seconds} second{}", if seconds == 1 { "" } else { "s" })
1328 }
1329}
1330
1331fn perish_reason_link(
1332 perish_reason: DeployDeployAppPerishReasonChoices,
1333 app_id: &str,
1334) -> Option<String> {
1335 match perish_reason {
1336 DeployDeployAppPerishReasonChoices::AppUnclaimed => Some(format!(
1337 "Claim it to keep it online: https://wasmer.io/apps/claim/{app_id}"
1338 )),
1339 DeployDeployAppPerishReasonChoices::UserPendingVerification => {
1340 Some("Verify now to keep it online: https://wasmer.io/verify".to_string())
1341 }
1342 DeployDeployAppPerishReasonChoices::UserRequested => None,
1343 }
1344}
1345
1346pub fn app_config_from_api(version: &DeployAppVersion) -> Result<AppConfigV1, anyhow::Error> {
1347 let app_id = version
1348 .app
1349 .as_ref()
1350 .context("app field on app version is empty")?
1351 .id
1352 .inner()
1353 .to_string();
1354
1355 let cfg = &version.user_yaml_config;
1356 let mut cfg = AppConfigV1::parse_yaml(cfg)
1357 .context("could not parse app config from backend app version")?;
1358
1359 cfg.app_id = Some(app_id);
1360 Ok(cfg)
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use super::format_duration_words;
1366 use time::Duration as TimeDuration;
1367
1368 #[test]
1369 fn format_duration_words_seconds() {
1370 assert_eq!(format_duration_words(TimeDuration::ZERO), "0 seconds");
1371 assert_eq!(format_duration_words(TimeDuration::seconds(1)), "1 second");
1372 assert_eq!(
1373 format_duration_words(TimeDuration::seconds(59)),
1374 "59 seconds"
1375 );
1376 }
1377
1378 #[test]
1379 fn format_duration_words_minutes() {
1380 assert_eq!(format_duration_words(TimeDuration::seconds(60)), "1 minute");
1381 assert_eq!(format_duration_words(TimeDuration::seconds(61)), "1 minute");
1382 assert_eq!(format_duration_words(TimeDuration::minutes(2)), "2 minutes");
1383 }
1384
1385 #[test]
1386 fn format_duration_words_hours() {
1387 assert_eq!(format_duration_words(TimeDuration::minutes(60)), "1 hour");
1388 assert_eq!(format_duration_words(TimeDuration::minutes(119)), "1 hour");
1389 assert_eq!(format_duration_words(TimeDuration::hours(5)), "5 hours");
1390 }
1391
1392 #[test]
1393 fn format_duration_words_days() {
1394 assert_eq!(format_duration_words(TimeDuration::hours(24)), "1 day");
1395 assert_eq!(format_duration_words(TimeDuration::hours(47)), "1 day");
1396 assert_eq!(format_duration_words(TimeDuration::days(3)), "3 days");
1397 assert_eq!(
1398 format_duration_words(TimeDuration::days(4) - TimeDuration::SECOND),
1399 "3 days"
1400 );
1401 }
1402}