Skip to main content

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    /// Load environment variables from a dotenv file for this deployment.
71    #[clap(long = "env-file", name = "PATH")]
72    pub env_file: Option<PathBuf>,
73
74    /// Do not wait for the app to become reachable.
75    #[clap(long)]
76    pub no_wait: bool,
77
78    /// Do not make the new app version the default (active) version.
79    /// This is useful for testing a deployment first, before moving it to "production".
80    #[clap(long)]
81    pub no_default: bool,
82
83    /// Do not persist the app ID under `app_id` field in app.yaml.
84    #[clap(long)]
85    pub no_persist_id: bool,
86
87    /// Specify the owner (user or namespace) of the app.
88    ///
89    /// If specified via this flag, the owner will be overridden.  Otherwise, the `app.yaml` is
90    /// inspected and, if there is no `owner` field in the spec file, the user will be prompted to
91    /// select the correct owner. If no owner is found in non-interactive mode the deployment will
92    /// fail.
93    #[clap(long)]
94    pub owner: Option<String>,
95
96    /// Specify the name (user or namespace) of the app to be deployed.
97    ///
98    /// If specified via this flag, the app_name will be overridden. Otherwise, the `app.yaml` is
99    /// inspected and, if there is no `name` field in the spec file, if running interactive the
100    /// user will be prompted to insert an app name, otherwise the deployment will fail.
101    #[clap(long, name = "name")]
102    pub app_name: Option<String>,
103
104    /// Whether or not to automatically bump the package version if publishing.
105    #[clap(long)]
106    pub bump: bool,
107
108    /// Don't print any message.
109    ///
110    /// The only message that will be printed is the one signaling the successfulness of the
111    /// operation.
112    #[clap(long)]
113    pub quiet: bool,
114
115    /// Use Wasmer's remote autobuild pipeline instead of building locally.
116    #[clap(long)]
117    pub build_remote: bool,
118
119    // - App creation -
120    /// A reference to the template to use when creating an app to deploy.
121    ///
122    /// It can be either an URL to a github repository - like
123    /// `https://github.com/wasmer-examples/php-wasmer-starter` -  or the name of a template that
124    /// will be searched for in the selected registry, like `astro-starter`.
125    #[clap(
126        long,
127        conflicts_with = "package",
128        conflicts_with = "use_local_manifest"
129    )]
130    pub template: Option<String>,
131
132    /// Name of the package to use when creating an app to deploy.
133    #[clap(
134        long,
135        conflicts_with = "template",
136        conflicts_with = "use_local_manifest"
137    )]
138    pub package: Option<String>,
139
140    /// Whether or not to search (and use) a local manifest when creating an app to deploy.
141    #[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            // if not interactive we can't prompt the user to choose the owner of the app.
216            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            // An env file applies only to this deployment and must not be
339            // written back into app.yaml.
340            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            &current_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                // Create already points back to deploy.
623                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        // We want to allow the user to specify the app name interactively.
638        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 the directory has an app.yaml, but no wasmer.toml manifest,
752        // ask the user to deploy with a remote build instead.
753        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                // We need to check if we have a manifest with the same name in the
817                // same directory as the `app.yaml`.
818                //
819                // Release v<insert current version> introduced a breaking change on the
820                // deployment flow, and we want old CI to explicitly fail.
821
822                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                                    // We have to write it right now.
851                                    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        // Don't override the package field.
1001        new_app_config.package = app_config.package.clone();
1002        // An env file applies only to this deployment and must not be written
1003        // back into app.yaml.
1004        new_app_config.env = app_config.env.clone();
1005        // [TODO]: check if name was added...
1006
1007        // If the config changed, write it back.
1008        if new_app_config != app_config {
1009            // We want to preserve unknown fields to allow for newer app.yaml
1010            // settings without requiring new CLI versions, so instead of just
1011            // serializing the new config, we merge it with the old one.
1012            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    // Original raw yaml config.
1037    // Present here to enable forwarding unknown fields to the backend, which
1038    // preserves forwards-compatibility for schema changes.
1039    pub original_config: Option<serde_yaml::value::Value>,
1040    /// Optional dotenv file overlaid on the app configuration for deployment.
1041    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    // TODO: respect allow_create flag
1146
1147    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    /// Wait for the app to be deployed.
1165    Deployed,
1166    /// Wait for the app to be deployed and ready.
1167    Reachable,
1168}
1169
1170/// Same as [Self::deploy], but also prints verbose information.
1171pub 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                // Should not follow redirects.
1230                .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                // Increase the sleep time between requests, up
1303                // to a reasonable maximum.
1304                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}