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