wasmer_cli/commands/app/volumes/
mod.rs

1use comfy_table::Table;
2use wasmer_backend_api::WasmerClient;
3
4use crate::commands::AsyncCliCommand;
5use crate::utils::render::CliRender;
6
7pub mod credentials;
8pub mod enable_s3;
9pub mod list;
10
11/// App volume management.
12#[derive(Debug, clap::Parser)]
13pub enum CmdAppVolumes {
14    Credentials(credentials::CmdAppVolumesCredentials),
15    List(list::CmdAppVolumesList),
16    RotateSecrets(credentials::rotate_secrets::CmdAppVolumesRotateSecrets),
17    EnableS3(enable_s3::CmdAppVolumesEnableS3),
18}
19
20#[async_trait::async_trait]
21impl AsyncCliCommand for CmdAppVolumes {
22    type Output = ();
23
24    async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
25        match self {
26            Self::Credentials(c) => {
27                c.run_async().await?;
28                Ok(())
29            }
30            Self::RotateSecrets(c) => {
31                c.run_async().await?;
32                Ok(())
33            }
34            Self::List(c) => {
35                c.run_async().await?;
36                Ok(())
37            }
38            Self::EnableS3(c) => {
39                c.run_async().await?;
40                Ok(())
41            }
42        }
43    }
44}
45
46/// A volume of an app: the single view every `wasmer app volume` subcommand
47/// operates on.
48///
49/// It unifies the persistent `AppVolume` node (the manageable entity with id, mount
50/// path, S3 state and credentials) with the active version's descriptor
51/// (friendly name, used size), joined by mount path. Volumes not declared in the
52/// active version (e.g. created via the dashboard) simply have no `name`/
53/// `used_size`.
54pub(crate) struct Volume {
55    pub id: wasmer_backend_api::types::Id,
56    pub volume_id: String,
57    pub mount_path: String,
58    pub s3_enabled: bool,
59    pub s3: Option<wasmer_backend_api::types::S3>,
60    pub name: Option<String>,
61    /// Used size in bytes, from the active version descriptor.
62    pub used_size: Option<i64>,
63}
64
65impl Volume {
66    /// A human-readable label for status messages and error lists.
67    pub fn label(&self) -> String {
68        match &self.name {
69            Some(name) => format!("{name} ({})", self.mount_path),
70            None => self.mount_path.clone(),
71        }
72    }
73}
74
75/// List the app's volumes: the persistent `AppVolume` nodes enriched with the
76/// friendly name and used size from the active version's descriptors, joined by
77/// mount path.
78pub(crate) async fn list_volumes(
79    client: &WasmerClient,
80    owner: &str,
81    app_name: &str,
82) -> Result<Vec<Volume>, anyhow::Error> {
83    let persistent =
84        wasmer_backend_api::query::get_deploy_app_volumes(client, owner, app_name).await?;
85
86    // The active-version descriptors carry the friendly name and used size.
87    // They're best-effort decoration, so don't fail the command if unavailable.
88    let version = wasmer_backend_api::query::get_app_volumes(client, owner, app_name)
89        .await
90        .unwrap_or_default();
91
92    let mut meta_by_mount: std::collections::HashMap<String, (String, Option<i64>)> =
93        std::collections::HashMap::new();
94    for descriptor in version {
95        let used_size = descriptor.used_size.map(|b| b.0);
96        for mount in descriptor.mount_paths {
97            meta_by_mount
98                .entry(mount.path.trim_end_matches('/').to_string())
99                .or_insert_with(|| (descriptor.name.clone(), used_size));
100        }
101    }
102
103    Ok(persistent
104        .into_iter()
105        .map(|volume| {
106            let meta = meta_by_mount.get(volume.mount_path.trim_end_matches('/'));
107            Volume {
108                id: volume.id,
109                volume_id: volume.volume_id,
110                mount_path: volume.mount_path,
111                s3_enabled: volume.s3_enabled,
112                s3: volume.s3,
113                name: meta.map(|(name, _)| name.clone()),
114                used_size: meta.and_then(|(_, used)| *used),
115            }
116        })
117        .collect())
118}
119
120/// Resolve a `--volume` selector to a single volume. The selector may be the
121/// friendly name, the mount path (exactly as shown by `wasmer app volume list`,
122/// e.g. `/data`), or the volume id.
123///
124/// The volume id is opaque and unique, so an exact id match is unambiguous and
125/// always wins: a volume can never be shadowed by another volume's (user-chosen)
126/// name or mount path. If a volume's name collides with a volume's id, the id
127/// always wins.
128pub(crate) fn select_volume_by_selector<'a>(
129    app_name: &str,
130    volumes: &'a [Volume],
131    selector: &str,
132) -> Result<&'a Volume, anyhow::Error> {
133    if let Some(volume) = volumes.iter().find(|v| v.volume_id == selector) {
134        return Ok(volume);
135    }
136
137    let matched: Vec<&Volume> = volumes
138        .iter()
139        .filter(|v| v.name.as_deref() == Some(selector) || v.mount_path == selector)
140        .collect();
141
142    match matched.as_slice() {
143        [] => {
144            let available = volumes
145                .iter()
146                .map(Volume::label)
147                .collect::<Vec<_>>()
148                .join(", ");
149            if available.is_empty() {
150                anyhow::bail!("App {app_name} has no volumes.");
151            }
152            anyhow::bail!(
153                "App {app_name} has no volume matching '{selector}'. Available volumes: {available}"
154            );
155        }
156        [volume] => Ok(volume),
157        multiple => {
158            let colliding = multiple
159                .iter()
160                .map(|v| format!("{} [id {}]", v.label(), v.volume_id))
161                .collect::<Vec<_>>()
162                .join(", ");
163            anyhow::bail!(
164                "'{selector}' matches multiple volumes of app {app_name}: {colliding}. \
165                 Pass the volume id to select one."
166            )
167        }
168    }
169}
170
171/// The `wasmer app volume list` row for a [`Volume`].
172#[derive(serde::Serialize)]
173pub(crate) struct VolumeListItem {
174    pub name: Option<String>,
175    pub mount_path: String,
176    pub volume_id: String,
177    pub used_size: Option<i64>,
178    pub s3_enabled: bool,
179}
180
181impl From<&Volume> for VolumeListItem {
182    fn from(volume: &Volume) -> Self {
183        Self {
184            name: volume.name.clone(),
185            mount_path: volume.mount_path.clone(),
186            volume_id: volume.volume_id.clone(),
187            used_size: volume.used_size,
188            s3_enabled: volume.s3_enabled,
189        }
190    }
191}
192
193impl VolumeListItem {
194    fn row(&self) -> Vec<String> {
195        vec![
196            self.name.clone().unwrap_or_else(|| "-".to_string()),
197            self.mount_path.clone(),
198            crate::types::format_disk_size_opt(
199                self.used_size.map(wasmer_backend_api::types::BigInt),
200            ),
201            if self.s3_enabled {
202                "enabled"
203            } else {
204                "disabled"
205            }
206            .to_string(),
207        ]
208    }
209}
210
211impl CliRender for VolumeListItem {
212    fn render_item_table(&self) -> String {
213        let row = self.row();
214        let mut table = Table::new();
215        table.add_rows([
216            vec!["Name".to_string(), row[0].clone()],
217            vec!["Mount path".to_string(), row[1].clone()],
218            vec!["Used size".to_string(), row[2].clone()],
219            vec!["S3".to_string(), row[3].clone()],
220        ]);
221        table.to_string()
222    }
223
224    fn render_list_table(items: &[Self]) -> String {
225        let mut table = Table::new();
226        table.set_header(vec!["Name", "Mount path", "Used size", "S3"]);
227        table.add_rows(items.iter().map(|item| item.row()));
228        table.to_string()
229    }
230}