use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::types::container::{ContainerListEntry, ContainerPort};
use crate::libpod::{urlencoded, API_PREFIX};
use super::Engine;
#[derive(Default)]
pub struct PsOptions {
pub all: bool,
pub quiet: bool,
pub json: bool,
}
#[derive(Default)]
pub struct PsFilterOptions {
pub services_only: bool,
pub services: Vec<String>,
pub status: Vec<String>,
pub filters: Vec<String>,
}
fn display_status(c: &ContainerListEntry) -> &str {
if c.status.is_empty() {
&c.state
} else {
&c.status
}
}
fn table_status(c: &ContainerListEntry) -> String {
let status = display_status(c);
let exited = c.state.eq_ignore_ascii_case("exited") || c.state.eq_ignore_ascii_case("dead");
if exited && !status.contains("Exited (") {
return format!("Exited ({})", c.exit_code.unwrap_or(0));
}
status.to_string()
}
fn name_of(c: &ContainerListEntry) -> String {
c.names.join(", ").trim_start_matches('/').to_string()
}
fn status_matches(status: &str, wanted: &[String]) -> bool {
if wanted.is_empty() {
return true;
}
let s = status.trim().to_ascii_lowercase();
wanted.iter().any(|w| {
let w = w.trim().to_ascii_lowercase();
!w.is_empty() && (s == w || s.starts_with(&w))
})
}
fn split_ps_filters(filters: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
let (mut status, mut names, mut unknown) = (Vec::new(), Vec::new(), Vec::new());
for f in filters {
match f.split_once('=') {
Some(("status", v)) => status.push(v.to_string()),
Some(("name", v)) => names.push(v.to_string()),
_ => unknown.push(f.clone()),
}
}
(status, names, unknown)
}
fn health_from_status(status: &str) -> &'static str {
let s = status.to_ascii_lowercase();
if s.contains("unhealthy") {
"unhealthy"
} else if s.contains("healthy") {
"healthy"
} else if s.contains("health: starting") {
"starting"
} else {
""
}
}
fn span_len(p: &ContainerPort) -> u16 {
p.range.filter(|&r| r > 0).unwrap_or(1)
}
fn widen_add(base: u16, offset: u16) -> u32 {
u32::from(base) + u32::from(offset)
}
fn display_host_ip(p: &ContainerPort) -> &str {
p.host_ip
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("0.0.0.0")
}
fn format_port_record(p: &ContainerPort) -> String {
let proto = p
.protocol
.as_deref()
.map(|proto| format!("/{proto}"))
.unwrap_or_default();
let host_ip = display_host_ip(p);
let hp = p.host_port.unwrap_or(0);
let n = span_len(p);
if n > 1 {
format!(
"{host_ip}:{hp}-{}->{}-{}{proto}",
widen_add(hp, n - 1),
p.container_port,
widen_add(p.container_port, n - 1),
)
} else {
format!("{host_ip}:{hp}->{}{proto}", p.container_port)
}
}
fn format_ports(ports: &[ContainerPort]) -> String {
ports
.iter()
.map(format_port_record)
.collect::<Vec<_>>()
.join(", ")
}
fn publishers(ports: &[ContainerPort]) -> Vec<serde_json::Value> {
let mut out = Vec::new();
for p in ports {
let n = span_len(p);
for i in 0..n {
out.push(serde_json::json!({
"URL": display_host_ip(p),
"TargetPort": widen_add(p.container_port, i),
"PublishedPort": p.host_port.map(|hp| widen_add(hp, i)),
"Protocol": p.protocol.as_deref().unwrap_or("tcp"),
}));
}
}
out
}
fn ps_json_row(c: &ContainerListEntry) -> serde_json::Value {
serde_json::json!({
"Name": name_of(c),
"Image": c.image,
"Project": c.labels.get("podup.project").cloned().unwrap_or_default(),
"Service": c.labels.get("podup.service").cloned().unwrap_or_default(),
"State": c.state,
"Status": display_status(c),
"Health": health_from_status(display_status(c)),
"ExitCode": c.exit_code.unwrap_or(0),
"Publishers": publishers(&c.ports),
"ID": c.id,
})
}
impl Engine {
pub async fn ps(&self, file: &ComposeFile) -> Result<()> {
self.ps_with_options(file, PsOptions::default()).await
}
pub async fn ps_with_options(&self, file: &ComposeFile, opts: PsOptions) -> Result<()> {
self.ps_filtered(file, opts, PsFilterOptions::default())
.await
}
pub async fn ps_filtered(
&self,
file: &ComposeFile,
opts: PsOptions,
filters: PsFilterOptions,
) -> Result<()> {
for name in &filters.services {
if !file.services.contains_key(name) {
return Err(ComposeError::ServiceNotFound(name.clone()));
}
}
if filters.services_only {
for name in file.services.keys() {
if filters.services.is_empty() || filters.services.iter().any(|s| s == name) {
println!("{name}");
}
}
return Ok(());
}
let (mut status_filter, name_filter, unknown) = split_ps_filters(&filters.filters);
if let Some(u) = unknown.first() {
return Err(ComposeError::Unsupported(format!(
"unsupported ps filter {u:?}: expected name=<NAME> or status=<STATE>"
)));
}
status_filter.extend(filters.status.iter().cloned());
let allowed_names: Option<std::collections::HashSet<String>> =
if filters.services.is_empty() {
None
} else {
Some(
filters
.services
.iter()
.filter_map(|n| file.services.get(n).map(|s| (n, s)))
.flat_map(|(n, s)| self.replica_names(n, s))
.collect(),
)
};
let all = opts.all || !status_filter.is_empty();
let label = format!("podup.project={}", self.project);
let filters = serde_json::json!({ "label": [label] });
let path = format!(
"{API_PREFIX}/containers/json?all={}&filters={}",
all,
urlencoded(&filters.to_string()),
);
let all_containers = self
.client
.get_json::<Vec<ContainerListEntry>>(&path)
.await
.map_err(ComposeError::Podman)?;
let containers: Vec<ContainerListEntry> = all_containers
.into_iter()
.filter(|c| {
let name = name_of(c);
allowed_names.as_ref().is_none_or(|set| {
c.names
.iter()
.any(|n| set.contains(n.trim_start_matches('/')))
}) && (status_matches(&c.state, &status_filter)
|| status_matches(&c.status, &status_filter))
&& (name_filter.is_empty() || name_filter.iter().any(|nf| name.contains(nf)))
})
.collect();
if opts.quiet {
for c in &containers {
println!("{}", c.id);
}
return Ok(());
}
if opts.json {
let rows: Vec<_> = containers.iter().map(ps_json_row).collect();
println!(
"{}",
serde_json::to_string_pretty(&rows).unwrap_or_default()
);
return Ok(());
}
let mut table = crate::ui::Table::new(&["NAME", "IMAGE", "STATUS", "PORTS"])
.cap(0, 48)
.cap(1, 48)
.status_col(2)
.identity_col(0);
for c in &containers {
table.push(vec![
name_of(c),
c.image.clone(),
table_status(c),
format_ports(&c.ports),
]);
}
table.print();
Ok(())
}
}
#[cfg(test)]
mod tests;