use futures_util::StreamExt;
use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::{urlencoded, LogOutput, API_PREFIX};
use super::Engine;
mod exec;
mod exec_interactive;
mod inspect;
mod inspect_util;
mod log_prefix;
mod ps;
pub(crate) mod terminal;
pub use ps::{PsFilterOptions, PsOptions};
pub use exec::ExecOptions;
pub(crate) use exec::{stdin_is_terminal, stdout_is_terminal};
use log_prefix::LinePrefixer;
pub use inspect::AttachOutcome;
#[derive(Default)]
pub struct ImagesOptions {
pub quiet: bool,
pub json: bool,
}
#[derive(Default)]
pub struct LogsOptions {
pub follow: bool,
pub tail: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub timestamps: bool,
}
#[derive(Default)]
pub struct LogsDisplay {
pub no_color: bool,
pub no_log_prefix: bool,
}
fn validate_log_filters(opts: &LogsOptions) -> Result<()> {
if let Some(tail) = &opts.tail {
if tail != "all" && tail.parse::<u64>().is_err() {
return Err(ComposeError::Unsupported(format!(
"invalid --tail value {tail:?}: expected a non-negative integer or 'all'"
)));
}
}
for (flag, value) in [("--since", &opts.since), ("--until", &opts.until)] {
if let Some(v) = value {
if !is_valid_log_time(v) {
return Err(ComposeError::Unsupported(format!(
"invalid {flag} value {v:?}: expected a duration (e.g. 10m, 1h30m), a Unix \
timestamp, or an RFC3339 time"
)));
}
}
}
Ok(())
}
fn is_valid_log_time(v: &str) -> bool {
if v.is_empty() {
return false;
}
if v.parse::<f64>().is_ok() {
return true;
}
if is_go_duration(v) {
return true;
}
let bytes = v.as_bytes();
bytes.len() >= 4
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& v.chars().all(|c| {
c.is_ascii_digit() || matches!(c, '-' | ':' | 't' | 'T' | 'z' | 'Z' | '.' | '+' | ' ')
})
}
fn is_go_duration(v: &str) -> bool {
let mut rest = v.strip_prefix('-').unwrap_or(v);
if rest.is_empty() {
return false;
}
let mut segments = 0;
while !rest.is_empty() {
let digits = rest.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.');
if digits.len() == rest.len() {
return false;
}
rest = digits;
let unit_len = ["ms", "ns", "us", "µs", "s", "m", "h"]
.into_iter()
.find(|u| rest.starts_with(u))
.map(str::len);
match unit_len {
Some(n) => rest = &rest[n..],
None => return false,
}
segments += 1;
}
segments > 0
}
pub(crate) fn display_label(container_name: &str, project: &str) -> String {
container_name
.strip_prefix(&format!("{project}-"))
.unwrap_or(container_name)
.to_string()
}
fn stop_on_write_error(container_name: &str, result: std::io::Result<()>) -> bool {
match result {
Ok(()) => false,
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => true,
Err(e) => {
tracing::warn!("logs {container_name}: cannot write output: {e}");
true
}
}
}
pub(super) fn stream_broke_mid_output(still_running: Option<bool>) -> bool {
!matches!(still_running, Some(false))
}
fn log_query(opts: &LogsOptions) -> String {
let mut q = format!(
"stdout=true&stderr=true&follow={}×tamps={}",
opts.follow, opts.timestamps
);
if let Some(tail) = &opts.tail {
q.push_str(&format!("&tail={}", urlencoded(tail)));
}
if let Some(since) = &opts.since {
q.push_str(&format!("&since={}", urlencoded(since)));
}
if let Some(until) = &opts.until {
q.push_str(&format!("&until={}", urlencoded(until)));
}
q
}
impl Engine {
pub async fn logs(
&self,
file: &ComposeFile,
service_name: Option<&str>,
follow: bool,
) -> Result<()> {
let targets: Vec<String> = service_name
.map(|s| vec![s.to_string()])
.unwrap_or_default();
self.logs_with_options(
file,
&targets,
LogsOptions {
follow,
..Default::default()
},
)
.await
}
pub async fn logs_with_options(
&self,
file: &ComposeFile,
target_services: &[String],
opts: LogsOptions,
) -> Result<()> {
self.logs_with_display(file, target_services, opts, LogsDisplay::default())
.await
}
pub async fn logs_with_display(
&self,
file: &ComposeFile,
target_services: &[String],
opts: LogsOptions,
display: LogsDisplay,
) -> Result<()> {
validate_log_filters(&opts)?;
let follow = opts.follow;
let prefix = !display.no_log_prefix;
let allow_color = !display.no_color;
let query = log_query(&opts);
for svc in target_services {
if !file.services.contains_key(svc) {
return Err(ComposeError::ServiceNotFound(svc.into()));
}
}
let selected: std::collections::HashSet<&str> =
target_services.iter().map(String::as_str).collect();
let mut first_err: Option<ComposeError> = None;
let mut targets: Vec<(String, bool)> = Vec::new();
for (n, s) in file
.services
.iter()
.filter(|(n, _)| selected.is_empty() || selected.contains(n.as_str()))
{
let is_tty = s.tty.unwrap_or(false);
let names = match self.live_replica_names(n, s).await {
Ok(names) => names,
Err(e) => {
tracing::warn!("logs: resolving replicas for service {n}: {e}");
first_err.get_or_insert(e);
continue;
}
};
for cname in names {
targets.push((cname, is_tty));
}
}
if targets.is_empty() {
if let Some(e) = first_err {
return Err(e);
}
}
let mut streamed_err: Option<ComposeError> = None;
let mut truncated_err: Option<ComposeError> = None;
let mut streamed_any = false;
let target_count = targets.len();
if follow && targets.len() > 1 {
let futs: Vec<_> = targets
.into_iter()
.map(|(container_name, is_tty)| {
let client = &self.client;
let query = query.clone();
async move {
let path = format!(
"{API_PREFIX}/containers/{}/logs?{query}",
urlencoded(&container_name),
);
let resp = match client.get_stream(&path).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("logs {container_name}: {e}");
return Some(e);
}
};
let mut stream = if is_tty {
crate::libpod::parse_raw(resp.into_body())
} else {
crate::libpod::parse_multiplexed(resp.into_body())
};
let label = display_label(&container_name, &self.project);
let mut out_pfx = LinePrefixer::new(&label, prefix, allow_color);
let mut err_pfx = LinePrefixer::new(&label, prefix, allow_color);
while let Some(msg) = stream.next().await {
let wrote = match msg {
Ok(LogOutput::StdOut { message }) => {
out_pfx.write(&mut std::io::stdout().lock(), &message)
}
Ok(LogOutput::StdErr { message }) => {
err_pfx.write(&mut std::io::stderr().lock(), &message)
}
Err(e) => {
let kind = e.stream_end_kind();
match stream_broke_mid_output(
self.container_still_running(&container_name).await,
) {
true => {
tracing::warn!(
"logs {container_name}: stream ended while the \
container was still running [{kind}]: {e}"
);
return Some(e);
}
false => {
tracing::warn!(
"logs {container_name}: stream ended as the \
container stopped [{kind}]"
);
}
}
break;
}
};
if stop_on_write_error(&container_name, wrote) {
break;
}
}
out_pfx.flush_tail(&mut std::io::stdout().lock());
err_pfx.flush_tail(&mut std::io::stderr().lock());
None
}
})
.collect();
let mut failures = 0usize;
for e in futures_util::future::join_all(futs)
.await
.into_iter()
.flatten()
{
failures += 1;
streamed_err.get_or_insert(ComposeError::Podman(e));
}
streamed_any = failures < target_count;
} else {
for (container_name, is_tty) in targets {
let path = format!(
"{API_PREFIX}/containers/{}/logs?{query}",
urlencoded(&container_name),
);
let resp = match self.client.get_stream(&path).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("logs {container_name}: {e}");
streamed_err.get_or_insert(ComposeError::Podman(e));
continue;
}
};
let mut stream = if is_tty {
crate::libpod::parse_raw(resp.into_body())
} else {
crate::libpod::parse_multiplexed(resp.into_body())
};
let mut out = std::io::stdout().lock();
let label = display_label(&container_name, &self.project);
let mut out_pfx = LinePrefixer::new(&label, prefix, allow_color);
let mut err_pfx = LinePrefixer::new(&label, prefix, allow_color);
while let Some(msg) = stream.next().await {
let wrote = match msg {
Ok(LogOutput::StdOut { message }) => out_pfx.write(&mut out, &message),
Ok(LogOutput::StdErr { message }) => {
err_pfx.write(&mut std::io::stderr().lock(), &message)
}
Err(e) => {
let kind = e.stream_end_kind();
match stream_broke_mid_output(
self.container_still_running(&container_name).await,
) {
true => {
tracing::warn!(
"logs {container_name}: stream ended while the container \
was still running [{kind}]: {e}"
);
truncated_err.get_or_insert(ComposeError::Podman(e));
}
false => {
tracing::warn!(
"logs {container_name}: stream ended as the container \
stopped [{kind}]"
);
}
}
break;
}
};
if stop_on_write_error(&container_name, wrote) {
break;
}
}
out_pfx.flush_tail(&mut out);
err_pfx.flush_tail(&mut std::io::stderr().lock());
streamed_any = true;
}
}
if let Some(e) = truncated_err {
return Err(e);
}
if streamed_any {
return Ok(());
}
streamed_err.map_or(Ok(()), Err)
}
pub(super) async fn container_still_running(&self, container_name: &str) -> Option<bool> {
let filters = serde_json::json!({ "label": [format!("podup.project={}", self.project)] });
let path = format!(
"{API_PREFIX}/containers/json?all=true&filters={}",
urlencoded(&filters.to_string()),
);
let entries = self
.client
.get_json::<Vec<crate::libpod::types::container::ContainerListEntry>>(&path)
.await
.ok()?;
entries
.iter()
.find(|e| {
e.names
.iter()
.any(|raw| raw.trim_start_matches('/') == container_name)
})
.map(|e| e.state == "running")
.or(Some(false))
}
async fn orphan_container_names(&self, file: &ComposeFile) -> Result<Vec<String>> {
let label = format!("podup.project={}", self.project);
let filters = serde_json::json!({ "label": [label] });
let path = format!(
"{API_PREFIX}/containers/json?all=true&filters={}",
urlencoded(&filters.to_string()),
);
let running = self
.client
.get_json::<Vec<crate::libpod::types::container::ContainerListEntry>>(&path)
.await
.map_err(ComposeError::Podman)?;
let known: std::collections::HashSet<String> = file
.services
.iter()
.flat_map(|(n, s)| self.replica_names(n, s))
.collect();
let names: Vec<String> = running
.iter()
.flat_map(|c| c.names.iter())
.map(|raw| raw.trim_start_matches('/').to_string())
.collect();
Ok(filter_orphans(names, &known))
}
pub async fn remove_orphans(&self, file: &ComposeFile) -> Result<()> {
let mut first_err: Option<ComposeError> = None;
for name in self.orphan_container_names(file).await? {
tracing::info!("removing orphan container {name}");
let rm_path = format!("{API_PREFIX}/containers/{}?force=true", urlencoded(&name));
match self.client.delete_ok(&rm_path).await {
Ok(()) => {}
Err(e) if e.is_status(404) => {}
Err(e) => {
tracing::debug!("orphan delete {name}: {e}");
first_err.get_or_insert(ComposeError::Podman(e));
}
}
}
if let Some(e) = first_err {
return Err(e);
}
Ok(())
}
pub async fn warn_orphans(&self, file: &ComposeFile) -> Result<()> {
let orphans = self.orphan_container_names(file).await?;
if !orphans.is_empty() {
tracing::warn!(
"found orphan container(s) ({}) for this project. If you removed or renamed a \
service in your compose file, run with --remove-orphans to remove them.",
orphans.join(", ")
);
}
Ok(())
}
}
fn filter_orphans(names: Vec<String>, known: &std::collections::HashSet<String>) -> Vec<String> {
names.into_iter().filter(|n| !known.contains(n)).collect()
}
#[cfg(test)]
mod attach_stream_tests;
#[cfg(test)]
mod tests;