use futures_util::StreamExt;
use serde_json::Value;
use crate::error::{ComposeError, Result};
use crate::libpod::{urlencoded, API_PREFIX};
use super::Engine;
#[derive(Debug, Clone, Default)]
pub struct EventsOptions {
pub since: Option<String>,
pub until: Option<String>,
pub filters: Vec<String>,
}
impl Engine {
pub async fn stream_events(&self, json: bool) -> Result<()> {
self.stream_events_with_options(json, &EventsOptions::default())
.await
}
pub async fn stream_events_with_options(&self, json: bool, opts: &EventsOptions) -> Result<()> {
let filters = build_event_filters(&self.project, &opts.filters)?;
let mut path = format!(
"{API_PREFIX}/events?stream=true&filters={}",
urlencoded(&filters.to_string()),
);
if let Some(since) = &opts.since {
path.push_str(&format!("&since={}", urlencoded(since)));
}
if let Some(until) = &opts.until {
path.push_str(&format!("&until={}", urlencoded(until)));
}
let resp = self
.client
.get_stream(&path)
.await
.map_err(ComposeError::Podman)?;
let mut stream = crate::libpod::parse_json_lines::<Value>(resp.into_body());
let bounded = opts.since.is_some() && opts.until.is_some();
if opts.until.is_some() && opts.since.is_none() {
tracing::warn!(
"events: --until without --since does not bound the feed; libpod keeps it open. \
Pass both to bound a window."
);
}
let mut broke: Option<crate::libpod::PodmanError> = None;
while let Some(event) = stream.next().await {
match event {
Ok(value) => println!("{}", format_event(&value, json)),
Err(e) => {
tracing::warn!("events: stream ended early [{}]: {e}", e.stream_end_kind());
broke = Some(e);
break;
}
}
}
if let Some(e) = broke {
return Err(ComposeError::Podman(e));
}
if bounded {
return Ok(());
}
Err(ComposeError::StreamTruncated(
"events stream ended on its own: an unbounded feed only ends when the client stops it"
.to_string(),
))
}
}
fn build_event_filters(project: &str, user_filters: &[String]) -> Result<Value> {
use serde_json::{Map, Value};
let mut map: Map<String, Value> = Map::new();
map.insert(
"label".to_string(),
Value::Array(vec![Value::String(format!("podup.project={project}"))]),
);
for f in user_filters {
let Some((key, value)) = f.split_once('=') else {
return Err(ComposeError::Unsupported(format!(
"malformed events filter {f:?}: expected KEY=VALUE (e.g. event=start)"
)));
};
match map
.entry(key.to_string())
.or_insert_with(|| Value::Array(Vec::new()))
{
Value::Array(arr) => arr.push(Value::String(value.to_string())),
other => *other = Value::Array(vec![Value::String(value.to_string())]),
}
}
Ok(Value::Object(map))
}
fn format_event(value: &Value, json: bool) -> String {
if json {
return serde_json::to_string(value).unwrap_or_default();
}
let typ = value.get("Type").and_then(Value::as_str).unwrap_or("");
let action = value
.get("Action")
.or_else(|| value.get("status"))
.and_then(Value::as_str)
.unwrap_or("");
let name = value
.pointer("/Actor/Attributes/name")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.unwrap_or("");
format_event_line(typ, action, name, crate::ui::stdout_colored() && !json)
}
fn format_event_line(typ: &str, action: &str, name: &str, colour: bool) -> String {
use crate::ui::{identity_style, paint, Style};
let typ = paint(Style::new().dimmed(), typ, colour);
let action = match crate::ui::action_or_status_style(action) {
Some(style) => paint(style, action, colour),
None => action.to_string(),
};
let name = paint(identity_style(name), name, colour && !name.is_empty());
format!("{typ} {action} {name}").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::{build_event_filters, format_event};
use serde_json::json;
#[test]
fn build_event_filters_scopes_to_project_label() {
let f = build_event_filters("demo", &[]).unwrap();
assert_eq!(f, json!({ "label": ["podup.project=demo"] }));
}
#[test]
fn build_event_filters_merges_user_predicates() {
let f = build_event_filters(
"demo",
&[
"event=start".to_string(),
"event=die".to_string(),
"type=container".to_string(),
],
)
.unwrap();
assert_eq!(
f,
json!({
"label": ["podup.project=demo"],
"event": ["start", "die"],
"type": ["container"],
})
);
}
#[test]
fn malformed_filter_is_rejected_not_dropped() {
let err = build_event_filters("demo", &["bogus".to_string()])
.expect_err("a filter with no `=` must not be silently ignored");
assert!(format!("{err}").contains("bogus"), "got {err}");
}
#[test]
fn formats_docker_compat_shape() {
let ev = json!({
"Type": "container",
"Action": "start",
"Actor": { "Attributes": { "name": "web-1" } },
});
assert_eq!(format_event(&ev, false), "container start web-1");
}
#[test]
fn formats_libpod_native_shape() {
let ev = json!({ "Type": "container", "status": "die", "id": "abc123" });
assert_eq!(format_event(&ev, false), "container die abc123");
}
#[test]
fn json_mode_emits_raw_object() {
let ev = json!({ "Type": "container", "Action": "start" });
let out = format_event(&ev, true);
assert!(out.contains("\"Type\":\"container\""));
assert!(out.contains("\"Action\":\"start\""));
}
}
#[cfg(test)]
mod event_colour_tests {
use super::format_event_line;
#[test]
fn plain_output_is_unchanged() {
assert_eq!(
format_event_line("container", "start", "proj-web-1", false),
"container start proj-web-1"
);
}
#[test]
fn action_and_name_are_tinted_apart() {
let died = format_event_line("container", "die", "proj-web-1", true);
let started = format_event_line("container", "start", "proj-web-1", true);
assert_ne!(
died,
started.replace("start", "die"),
"die and start must differ by more than the verb"
);
}
#[test]
fn an_empty_name_is_not_painted() {
let out = format_event_line("network", "create", "", true);
assert!(
out.ends_with("create\u{1b}[0m") || !out.ends_with("\u{1b}[0m "),
"{out:?}"
);
}
}
#[cfg(test)]
#[cfg(unix)]
mod stream_end_tests {
use crate::engine::fake_podman::{self, FakeReply};
use crate::engine::{Engine, EventsOptions};
fn engine(fake: &fake_podman::FakePodman) -> Engine {
Engine::with_base_dir(fake.client(), "proj".into(), std::env::temp_dir())
}
fn fake(reply: fn() -> FakeReply) -> fake_podman::FakePodman {
fake_podman::start_replying(move |_method, _target| reply())
}
fn one_event() -> Vec<String> {
vec![r#"{"Type":"container","Action":"start","id":"abc"}"#.to_string()]
}
fn bounded() -> EventsOptions {
EventsOptions {
since: Some("2026-01-01T00:00:00Z".to_string()),
until: Some("2026-01-01T01:00:00Z".to_string()),
..Default::default()
}
}
#[tokio::test]
async fn an_unbounded_feed_that_ends_cleanly_is_still_a_failure() {
let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
let err = engine(&fake)
.stream_events_with_options(false, &EventsOptions::default())
.await
.expect_err("only the client ends an unbounded feed, so any end is unexpected");
assert!(
matches!(err, crate::error::ComposeError::StreamTruncated(_)),
"expected the intent verdict, got {err:?}"
);
}
#[tokio::test]
async fn an_unbounded_feed_cut_mid_body_is_a_failure() {
let fake = fake(|| FakeReply::ChunkedTruncated(one_event()));
let err = engine(&fake)
.stream_events_with_options(false, &EventsOptions::default())
.await
.expect_err("a severed unbounded feed is a failure too");
assert!(
matches!(err, crate::error::ComposeError::Podman(_)),
"the transport error must survive so the operator sees the cause, got {err:?}"
);
}
#[tokio::test]
async fn a_bounded_feed_that_ends_is_success() {
let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
engine(&fake)
.stream_events_with_options(false, &bounded())
.await
.expect("a bounded feed reaching the end of its window succeeded");
}
#[tokio::test]
async fn until_without_since_is_not_a_bounded_feed() {
let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
let opts = EventsOptions {
until: Some("2026-01-01T00:00:00Z".to_string()),
..Default::default()
};
let err = engine(&fake)
.stream_events_with_options(false, &opts)
.await
.expect_err("until alone leaves the feed unbounded, so any end is unexpected");
assert!(
matches!(err, crate::error::ComposeError::StreamTruncated(_)),
"expected the unbounded verdict, got {err:?}"
);
}
#[tokio::test]
async fn a_bounded_feed_cut_mid_body_is_a_failure() {
let fake = fake(|| FakeReply::ChunkedTruncated(one_event()));
let err = engine(&fake)
.stream_events_with_options(false, &bounded())
.await
.expect_err("a severed window is a failed read, bounded or not");
assert!(
matches!(err, crate::error::ComposeError::Podman(_)),
"the transport error must survive so the operator sees the cause, got {err:?}"
);
}
}