wasmer_cli/commands/app/
deploy.rs

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