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