From 51482c9e0169ab6c9b8dc0c7656c49c9095e3f91 Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:45:46 -0500 Subject: [PATCH] feat(cli): volumes command to list a project's named volumes docker compose volumes [SERVICE...] was unimplemented. Add it: lists the top-level named volumes (resolved to their on-host names), or just those mounted by the given services. Supports -q/--quiet and --format table|json, matching the other list commands. Bind mounts and anonymous volumes are omitted (no top-level name), as in docker compose. Refs #410. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- internal/cli/mod.rs | 30 ++++--- internal/cli/types.rs | 16 ++++ internal/dispatch.rs | 16 ++++ internal/engine/mod.rs | 2 + internal/engine/volumes_list.rs | 139 +++++++++++++++++++++++++++++++ internal/lib.rs | 2 +- tests/engine_integration/cli3.rs | 41 +++++++++ 7 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 internal/engine/volumes_list.rs diff --git a/internal/cli/mod.rs b/internal/cli/mod.rs index 632af35..268511d 100644 --- a/internal/cli/mod.rs +++ b/internal/cli/mod.rs @@ -9,7 +9,7 @@ use clap_complete::Shell; mod parse; mod types; use parse::parse_scale_pair; -pub(crate) use types::{ConfigFormat, OutputFormat, RmiScope}; +pub(crate) use types::{ConfigFormat, GenerateCommands, OutputFormat, RmiScope}; #[derive(Parser)] #[command(name = "podup", version)] @@ -350,6 +350,19 @@ pub(crate) enum Commands { #[arg(long)] index: Option, }, + /// List the project's named volumes. + #[command(alias = "volume")] + Volumes { + /// Only display volume names. + #[arg(short, long)] + quiet: bool, + /// Output format. + #[arg(long, value_enum, default_value_t = OutputFormat::Table)] + format: OutputFormat, + /// Show only volumes mounted by these services. + #[arg(trailing_var_arg = true)] + services: Vec, + }, /// List images used by services. #[command(alias = "image")] Images { @@ -482,18 +495,3 @@ pub(crate) enum Commands { shell: Shell, }, } - -#[derive(Subcommand)] -pub(crate) enum GenerateCommands { - /// Translate the compose file into Podman Quadlet unit files. - /// - /// Emits one `.container` per service plus `.network` and `.volume` units. - /// Without --output the units are printed to stdout; warnings about fields - /// with no Quadlet mapping go to stderr. - Quadlet { - /// Directory to write the unit files into (e.g. - /// ~/.config/containers/systemd). Prints to stdout when omitted. - #[arg(short, long)] - output: Option, - }, -} diff --git a/internal/cli/types.rs b/internal/cli/types.rs index 9a2e288..640e862 100644 --- a/internal/cli/types.rs +++ b/internal/cli/types.rs @@ -30,3 +30,19 @@ pub(crate) enum ConfigFormat { /// JSON. Json, } + +/// Subcommands of `generate`. +#[derive(clap::Subcommand)] +pub(crate) enum GenerateCommands { + /// Translate the compose file into Podman Quadlet unit files. + /// + /// Emits one `.container` per service plus `.network` and `.volume` units. + /// Without --output the units are printed to stdout; warnings about fields + /// with no Quadlet mapping go to stderr. + Quadlet { + /// Directory to write the unit files into (e.g. + /// ~/.config/containers/systemd). Prints to stdout when omitted. + #[arg(short, long)] + output: Option, + }, +} diff --git a/internal/dispatch.rs b/internal/dispatch.rs index c42af76..1626250 100644 --- a/internal/dispatch.rs +++ b/internal/dispatch.rs @@ -274,6 +274,22 @@ pub(crate) async fn dispatch( .port_with_index(file, &service, private_port, &proto, index) .await? } + Commands::Volumes { + quiet, + format, + services, + } => { + engine + .list_volumes( + file, + &services, + podup::VolumesOptions { + quiet, + json: format == OutputFormat::Json, + }, + ) + .await? + } Commands::Images { quiet, format } => { engine .images_with_options( diff --git a/internal/engine/mod.rs b/internal/engine/mod.rs index 8592afe..f132d55 100644 --- a/internal/engine/mod.rs +++ b/internal/engine/mod.rs @@ -25,6 +25,8 @@ mod staging; mod stats; pub use staging::is_safe_project_name; mod volume; +mod volumes_list; +pub use volumes_list::VolumesOptions; mod volume_mounts; #[cfg(feature = "watch")] mod watch; diff --git a/internal/engine/volumes_list.rs b/internal/engine/volumes_list.rs new file mode 100644 index 0000000..45a7830 --- /dev/null +++ b/internal/engine/volumes_list.rs @@ -0,0 +1,139 @@ +//! `volumes` — list the named volumes declared by a compose project. +//! +//! Mirrors `docker compose volumes [SERVICE...]`: with no services it lists +//! every top-level `volumes:` entry; with services it lists only the named +//! volumes those services mount. Anonymous/bind mounts are not listed (they +//! have no top-level name), matching docker compose. + +use std::collections::BTreeSet; + +use crate::compose::types::{ComposeFile, VolumeMount}; +use crate::error::Result; + +use super::Engine; + +/// Options for [`Engine::list_volumes`], mirroring `docker compose volumes`. +#[derive(Default)] +pub struct VolumesOptions { + /// Print only volume names, `-q/--quiet`. + pub quiet: bool, + /// Emit a JSON array instead of the table, `--format json`. + pub json: bool, +} + +impl Engine { + /// List the project's named volumes (`docker compose volumes`). When + /// `services` is non-empty, only volumes mounted by those services are shown. + pub async fn list_volumes( + &self, + file: &ComposeFile, + services: &[String], + opts: VolumesOptions, + ) -> Result<()> { + let keys = self.selected_volume_keys(file, services); + + // (declared key, resolved on-host name, driver, external) + let rows: Vec<(String, String, String, bool)> = keys + .iter() + .map(|key| { + let cfg = file.volumes.get(key.as_str()).and_then(|c| c.as_ref()); + let external = cfg.and_then(|c| c.external).unwrap_or(false); + let name = match cfg.and_then(|c| c.name.as_deref()) { + Some(n) => n.to_string(), + None if external => key.to_string(), + None => format!("{}_{}", self.project, key), + }; + let driver = cfg + .and_then(|c| c.driver.clone()) + .unwrap_or_else(|| "local".into()); + (key.to_string(), name, driver, external) + }) + .collect(); + + if opts.quiet { + for (_, name, _, _) in &rows { + println!("{name}"); + } + return Ok(()); + } + if opts.json { + let arr: Vec<_> = rows + .iter() + .map(|(_, name, driver, external)| { + serde_json::json!({ "Name": name, "Driver": driver, "External": external }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default()); + return Ok(()); + } + + println!("{:<40} {:<12}", "NAME", "DRIVER"); + for (_, name, driver, _) in &rows { + println!("{name:<40} {driver:<12}"); + } + Ok(()) + } + + /// The top-level volume keys to list: all of them, or just those mounted by + /// `services` (in declaration order), deduplicated. + fn selected_volume_keys(&self, file: &ComposeFile, services: &[String]) -> Vec { + if services.is_empty() { + return file.volumes.keys().cloned().collect(); + } + let used: BTreeSet = services + .iter() + .filter_map(|s| file.services.get(s)) + .flat_map(|svc| svc.volumes.iter().filter_map(mount_source_name)) + .filter(|src| file.volumes.contains_key(src)) + .collect(); + file.volumes + .keys() + .filter(|k| used.contains(k.as_str())) + .cloned() + .collect() + } +} + +/// The source (named-volume) component of a mount, if any. Bind mounts and +/// anonymous volumes (no source) return `None`. +fn mount_source_name(m: &VolumeMount) -> Option { + match m { + VolumeMount::Short(s) => { + let parts: Vec<&str> = s.splitn(3, ':').collect(); + // `src:target[:opts]` — a leading `.`/`/`/`~` is a bind path, not a name. + if parts.len() >= 2 && !parts[0].starts_with(['.', '/', '~']) { + Some(parts[0].to_string()) + } else { + None + } + } + VolumeMount::Long { source, .. } => source.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::mount_source_name; + use crate::compose::types::VolumeMount; + + #[test] + fn named_volume_short_form_has_source() { + assert_eq!( + mount_source_name(&VolumeMount::Short("data:/var/lib".into())), + Some("data".to_string()) + ); + } + + #[test] + fn bind_and_anonymous_have_no_source() { + assert_eq!( + mount_source_name(&VolumeMount::Short("./host:/c".into())), + None + ); + assert_eq!( + mount_source_name(&VolumeMount::Short("/abs:/c".into())), + None + ); + assert_eq!(mount_source_name(&VolumeMount::Short("/data".into())), None); + } +} diff --git a/internal/lib.rs b/internal/lib.rs index 4cdc3c9..97bc4fd 100644 --- a/internal/lib.rs +++ b/internal/lib.rs @@ -31,7 +31,7 @@ pub use compose::{ pub use engine::{ is_safe_project_name, list_projects, BuildOptions, CpOptions, Engine, ExecOptions, ImagesOptions, LogsOptions, LsOptions, ProjectLock, PsOptions, PullOptions, PushOptions, - RunOptions, RunOverrides, + RunOptions, RunOverrides, VolumesOptions, }; pub use error::{ComposeError, Result}; pub use libpod::Client; diff --git a/tests/engine_integration/cli3.rs b/tests/engine_integration/cli3.rs index 7a2f65f..f826cfd 100644 --- a/tests/engine_integration/cli3.rs +++ b/tests/engine_integration/cli3.rs @@ -365,3 +365,44 @@ async fn cli_start_wait_returns_after_starting() { run(&["-f", c, "-p", &proj, "down"]); } + +#[tokio::test] +async fn cli_volumes_lists_named_volumes() { + if super::podman().await.is_none() { + return; + } + let dir = tempdir().unwrap(); + let proj = format!("t{}-vols", std::process::id()); + let compose = dir.path().join("docker-compose.yml"); + fs::write( + &compose, + "services:\n web:\n image: alpine:latest\n command: [\"sleep\", \"infinity\"]\n volumes:\n - data:/data\nvolumes:\n data:\n", + ) + .unwrap(); + let c = compose.to_str().unwrap(); + + // -q prints only the resolved on-host name ({proj}_data). + let out = run(&["-f", c, "-p", &proj, "volumes", "-q"]); + assert!(out.status.success(), "volumes -q failed: {:?}", out.stderr); + let names = String::from_utf8_lossy(&out.stdout); + assert!( + names.lines().any(|l| l.trim() == format!("{proj}_data")), + "volumes -q must list the resolved volume name, got: {names:?}" + ); + + // JSON format carries the same name. + let json = run(&["-f", c, "-p", &proj, "volumes", "--format", "json"]); + assert!( + json.status.success(), + "volumes --format json failed: {:?}", + json.stderr + ); + let parsed: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&json.stdout).trim()).expect("valid JSON"); + assert!( + parsed + .as_array() + .is_some_and(|a| a.iter().any(|v| v["Name"] == format!("{proj}_data"))), + "volumes --format json must include the volume: {parsed}" + ); +}