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