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