Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions internal/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! Command-line interface definitions for `podup`.

use std::path::PathBuf;
Expand All @@ -9,7 +9,7 @@
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)]
Expand Down Expand Up @@ -350,6 +350,19 @@
#[arg(long)]
index: Option<u32>,
},
/// 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<String>,
},
/// List images used by services.
#[command(alias = "image")]
Images {
Expand Down Expand Up @@ -482,18 +495,3 @@
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<PathBuf>,
},
}
16 changes: 16 additions & 0 deletions internal/cli/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
},
}
16 changes: 16 additions & 0 deletions internal/dispatch.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! Command dispatch: map a parsed `Commands` to engine calls.
//!
//! Split out of `main.rs` to keep that file within the source line limit as the
Expand Down Expand Up @@ -274,6 +274,22 @@
.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(
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
139 changes: 139 additions & 0 deletions internal/engine/volumes_list.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
if services.is_empty() {
return file.volumes.keys().cloned().collect();
}
let used: BTreeSet<String> = 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<String> {
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);
}
}
2 changes: 1 addition & 1 deletion internal/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions tests/engine_integration/cli3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
Loading