use futures_util::StreamExt;
use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::types::image::ImageInspect;
use crate::libpod::{urlencoded, LogOutput, API_PREFIX};
use super::inspect_util::{
align_top_columns, dedup_preserving_order, is_running_status, parse_port_proto, select_replica,
split_repo_tag,
};
use super::Engine;
impl Engine {
pub async fn top(&self, file: &ComposeFile, target_services: &[String]) -> Result<()> {
self.top_with_options(file, target_services, false).await
}
pub async fn top_with_options(
&self,
file: &ComposeFile,
target_services: &[String],
json: bool,
) -> Result<()> {
let names: Vec<String> = if target_services.is_empty() {
file.services.keys().cloned().collect()
} else {
for name in target_services {
if !file.services.contains_key(name) {
return Err(crate::error::ComposeError::ServiceNotFound(name.clone()));
}
}
dedup_preserving_order(target_services)
};
let mut json_rows: Vec<serde_json::Value> = Vec::new();
for name in &names {
let service = &file.services[name];
for container_name in self.live_replica_names(name, service).await? {
let path = format!(
"{API_PREFIX}/containers/{}/top",
urlencoded(&container_name),
);
match self
.client
.get_json::<crate::libpod::types::container::TopResponse>(&path)
.await
{
Ok(result) if json => json_rows.push(serde_json::json!({
"Container": container_name,
"Titles": result.titles,
"Processes": result.processes,
})),
Ok(result) => {
crate::ui::print_bold_header(&container_name);
let titles = result.titles.clone().unwrap_or_default();
let processes = result.processes.clone().unwrap_or_default();
let aligned = align_top_columns(&titles, &processes);
if let Some((header, rows)) = aligned.split_first() {
crate::ui::print_bold_header(header);
for row in rows {
println!("{row}");
}
}
}
Err(e) if e.is_status(404) => {
tracing::debug!("top {container_name}: {e}")
}
Err(e) => return Err(ComposeError::Podman(e)),
}
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&json_rows).unwrap_or_default()
);
}
Ok(())
}
pub async fn port(
&self,
file: &ComposeFile,
service_name: &str,
private_port: &str,
proto: &str,
) -> Result<()> {
self.port_with_index(file, service_name, private_port, proto, None)
.await
}
pub async fn port_with_index(
&self,
file: &ComposeFile,
service_name: &str,
private_port: &str,
proto: &str,
index: Option<u32>,
) -> Result<()> {
let (port, proto) = parse_port_proto(private_port, proto)?;
let service = file
.services
.get(service_name)
.ok_or_else(|| crate::error::ComposeError::ServiceNotFound(service_name.into()))?;
let live = self.live_replica_names(service_name, service).await?;
let container_name = select_replica(live, service_name, index)?;
let path = format!(
"{API_PREFIX}/containers/{}/json",
urlencoded(&container_name),
);
let info = match self
.client
.get_json::<crate::libpod::types::container::ContainerInspect>(&path)
.await
{
Ok(info) => info,
Err(e) if e.is_status(404) => {
return Err(crate::error::ComposeError::ServiceNotFound(format!(
"{service_name} (no running container '{container_name}')"
)));
}
Err(e) => return Err(ComposeError::Podman(e)),
};
let key = format!("{port}/{proto}");
let binding = info
.network_settings
.and_then(|ns| ns.ports.get(&key).cloned().flatten())
.and_then(|bindings| bindings.into_iter().next());
match binding {
Some(b) => {
let host = b.host_ip.as_deref().unwrap_or("0.0.0.0");
let port = b.host_port.as_deref().unwrap_or("");
println!("{host}:{port}");
}
None => println!(),
}
Ok(())
}
pub async fn images(&self, file: &ComposeFile) -> Result<()> {
self.images_with_options(file, super::ImagesOptions::default())
.await
}
pub async fn images_with_options(
&self,
file: &ComposeFile,
opts: super::ImagesOptions,
) -> Result<()> {
self.images_with_services(file, &[], opts).await
}
pub async fn images_with_services(
&self,
file: &ComposeFile,
target_services: &[String],
opts: super::ImagesOptions,
) -> Result<()> {
for name in target_services {
if !file.services.contains_key(name) {
return Err(ComposeError::ServiceNotFound(name.clone()));
}
}
let mut rows: Vec<(String, String, String, String)> = Vec::new();
for (name, service) in &file.services {
if !target_services.is_empty() && !target_services.iter().any(|t| t == name) {
continue;
}
let image_ref = match (&service.image, &service.build) {
(Some(img), _) => img.clone(),
(None, Some(build)) => {
super::super::build::primary_build_tag(&self.project, name, None, build.tags())
}
(None, None) => continue,
};
let (repo, tag) = split_repo_tag(&image_ref);
let path = format!("{API_PREFIX}/images/{}/json", urlencoded(&image_ref));
match self.client.get_json::<ImageInspect>(&path).await {
Ok(img) => {
let id = img.id.trim_start_matches("sha256:").get(..12).unwrap_or("");
rows.push((name.clone(), repo, tag, id.to_string()));
}
Err(e) if e.is_status(404) => {
tracing::debug!("images {name}: not present ({e})");
rows.push((name.clone(), repo, tag, String::new()));
}
Err(e) => return Err(ComposeError::Podman(e)),
}
}
if opts.quiet {
let mut seen = std::collections::HashSet::new();
for (_, _, _, id) in &rows {
if !id.is_empty() && seen.insert(id.as_str()) {
println!("{id}");
}
}
return Ok(());
}
if opts.json {
let json: Vec<_> = rows
.iter()
.map(|(svc, repo, tag, id)| {
serde_json::json!({
"Service": svc, "Repository": repo, "Tag": tag, "ID": id,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
return Ok(());
}
let mut table = crate::ui::Table::new(&["SERVICE", "REPOSITORY", "TAG", "IMAGE ID"])
.cap(0, 48)
.cap(1, 48)
.cap(2, 24);
for (svc, repo, tag, id) in &rows {
table.push(vec![svc.clone(), repo.clone(), tag.clone(), id.clone()]);
}
table.print();
Ok(())
}
pub async fn attach(&self, file: &ComposeFile, service_name: &str) -> Result<()> {
self.attach_with_index(file, service_name, None).await
}
pub async fn attach_with_index(
&self,
file: &ComposeFile,
service_name: &str,
index: Option<u32>,
) -> Result<()> {
let service = file
.services
.get(service_name)
.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
let mut live = self
.list_project_container_names(Some(service_name))
.await?;
live.sort();
let container = match index {
Some(i) => {
let idx = (i as usize).checked_sub(1).ok_or_else(|| {
ComposeError::Unsupported(format!("attach: --index must be >= 1 (got {i})"))
})?;
live.into_iter().nth(idx).ok_or_else(|| {
ComposeError::ServiceNotFound(format!("{service_name} (replica index {i})"))
})?
}
None => live.into_iter().next().ok_or_else(|| {
ComposeError::Unsupported(format!(
"attach: no running container for service '{service_name}'"
))
})?,
};
let is_tty = service.tty.unwrap_or(false);
let inspect_path = format!("{API_PREFIX}/containers/{}/json", urlencoded(&container));
let info = self
.client
.get_json::<crate::libpod::types::container::ContainerInspect>(&inspect_path)
.await
.map_err(ComposeError::Podman)?;
let status = info.state.and_then(|s| s.status).unwrap_or_default();
if !is_running_status(&status) {
let shown = if status.is_empty() {
"unknown"
} else {
&status
};
return Err(ComposeError::Unsupported(format!(
"cannot attach to {container}: container is not running (state: {shown})"
)));
}
let path = format!(
"{API_PREFIX}/containers/{}/logs?{}",
urlencoded(&container),
attach_log_query(),
);
let resp = match self.client.get_stream(&path).await {
Ok(r) => r,
Err(e) if e.is_status(404) => {
return Err(ComposeError::NotRunning(service_name.into()))
}
Err(e) => return Err(ComposeError::Podman(e)),
};
let mut stream = if is_tty {
crate::libpod::parse_raw(resp.into_body())
} else {
crate::libpod::parse_multiplexed(resp.into_body())
};
while let Some(msg) = stream.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
print!("{}", String::from_utf8_lossy(&message));
}
Ok(LogOutput::StdErr { message }) => {
eprint!("{}", String::from_utf8_lossy(&message));
}
Err(_) => break,
}
}
Ok(())
}
pub async fn attach_logs(&self, file: &ComposeFile) -> Result<()> {
self.attach_logs_with_options(file, false).await
}
pub async fn attach_logs_with_options(
&self,
file: &ComposeFile,
timestamps: bool,
) -> Result<()> {
let attached: Vec<(String, String, bool)> = file
.services
.iter()
.filter(|(_, s)| s.attach.unwrap_or(true))
.flat_map(|(name, s)| {
let proj_prefix = format!("{}-", self.project);
let is_tty = s.tty.unwrap_or(false);
self.replica_names(name, s).into_iter().map(move |cname| {
let display = cname
.strip_prefix(proj_prefix.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| cname.clone());
(display, cname, is_tty)
})
})
.collect();
if attached.is_empty() {
return Ok(());
}
let streams: Vec<_> = attached
.iter()
.map(|(display, cname, is_tty)| {
let prefix = display.clone();
let path = format!(
"{API_PREFIX}/containers/{}/logs?stdout=true&stderr=true&follow=true×tamps={timestamps}",
urlencoded(cname),
);
let client = &self.client;
let is_tty = *is_tty;
async move {
let resp = match client.get_stream(&path).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("attach_logs {prefix}: {e}");
return;
}
};
let mut stream = if is_tty {
crate::libpod::parse_raw(resp.into_body())
} else {
crate::libpod::parse_multiplexed(resp.into_body())
};
while let Some(msg) = stream.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
print!("{prefix} | {}", String::from_utf8_lossy(&message));
}
Ok(LogOutput::StdErr { message }) => {
eprint!("{prefix} | {}", String::from_utf8_lossy(&message));
}
Err(_) => break,
}
}
}
})
.collect();
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
tokio::select! {
_ = futures_util::future::join_all(streams) => {}
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
tokio::select! {
_ = futures_util::future::join_all(streams) => {}
_ = tokio::signal::ctrl_c() => {}
}
Ok(())
}
}
fn attach_log_query() -> &'static str {
"stdout=true&stderr=true&follow=true&tail=0"
}
#[cfg(test)]
mod tests {
use super::attach_log_query;
#[test]
fn attach_query_suppresses_log_backlog() {
let q = attach_log_query();
assert!(q.contains("follow=true"), "got: {q}");
assert!(q.contains("tail=0"), "got: {q}");
}
}