bread/breadd/src/adapters/podman.rs

304 lines
10 KiB
Rust

use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::{json, Value};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::adapters::Adapter;
/// Watches `podman events --format json` for container lifecycle changes and
/// forwards them as [`RawEvent`]s.
///
/// This is the first adapter in the codebase wrapping a child process rather
/// than a socket/D-Bus/netlink connection, so the process-lifecycle handling
/// here (kill-on-drop, treating any exit as an error so the supervisor
/// retries) is bespoke rather than following an existing pattern.
#[derive(Clone, Debug)]
pub struct PodmanAdapter;
impl PodmanAdapter {
pub fn new() -> Self {
Self
}
}
impl Default for PodmanAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Adapter for PodmanAdapter {
fn name(&self) -> &'static str {
"podman"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("podman adapter starting");
// kill_on_drop(true) ensures that if this future is cancelled (e.g. the
// supervisor tears the adapter down on daemon shutdown, or `tokio::select!`
// in Manager::spawn_adapter races it against the shutdown signal), the
// `podman events` child is killed rather than left running as an orphan
// with its stdout pipe silently discarded.
let mut child = match Command::new("podman")
.args(["events", "--format", "json"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!("podman binary not found; will retry on backoff");
return Err(anyhow!("podman binary not found: {e}"));
}
Err(e) => {
return Err(anyhow!("failed to spawn podman events: {e}"));
}
};
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("podman events: child had no stdout"))?;
let mut lines = BufReader::new(stdout).lines();
loop {
let line = match lines.next_line().await {
Ok(Some(line)) => line,
Ok(None) => {
// EOF: the child's stdout closed, meaning the process exited.
// Never surface this as Ok(()) — the supervisor treats a clean
// `run()` return as "stop forever," but a dead `podman events`
// process is exactly the kind of thing we want retried.
let _ = child.kill().await;
return Err(anyhow!("podman events exited"));
}
Err(e) => {
let _ = child.kill().await;
return Err(anyhow!("podman events read error: {e}"));
}
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
debug!("podman events: skipping unparseable line: {e}");
continue;
}
};
if let Some((kind, payload)) = map_podman_event(&value) {
if tx
.send(RawEvent {
source: AdapterSource::Podman,
kind,
payload,
timestamp: now_unix_ms(),
})
.await
.is_err()
{
let _ = child.kill().await;
return Err(anyhow!("podman adapter: downstream channel closed"));
}
}
}
}
}
/// Parses a single `podman events --format json` line and maps it to a
/// `(kind, payload)` pair for `RawEvent`, or `None` if the event should be
/// ignored.
///
/// Only `Type == "container"` events are handled. Action mapping:
/// - `start` -> `container.started`
/// - `died` -> `container.stopped`
/// - `stop` / `remove` -> ignored (see de-dup note below)
/// - `health_status` -> `container.health_status`
/// - anything else -> ignored
///
/// De-dup choice: podman commonly emits `died` followed by `stop` (and
/// sometimes `remove`) for a single container exit. Emitting on all three
/// would fire `container.stopped` multiple times for one real-world
/// transition, which is worse for Lua module authors (who'd need to
/// de-duplicate themselves) than missing the rare case where a container is
/// stopped without ever having been in a running state that produced `died`.
/// `died` fires in the overwhelmingly common paths (normal exit, kill, crash),
/// so it's used as the sole trigger for `container.stopped` and `stop`/`remove`
/// are dropped.
fn map_podman_event(value: &Value) -> Option<(String, Value)> {
let event_type = value.get("Type").and_then(|v| v.as_str())?;
if event_type != "container" {
return None;
}
let action = value.get("Action").and_then(|v| v.as_str())?;
let actor = value.get("Actor");
let id = actor
.and_then(|a| a.get("ID"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let attributes = actor.and_then(|a| a.get("Attributes"));
let name = attributes
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let image = attributes
.and_then(|a| a.get("image"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
match action {
"start" => Some((
"container.started".to_string(),
json!({
"id": id,
"name": name,
"image": image,
}),
)),
"died" => Some((
"container.stopped".to_string(),
json!({
"id": id,
"name": name,
}),
)),
"stop" | "remove" => {
// Intentionally ignored — see de-dup note on map_podman_event above.
None
}
"health_status" => {
let health = attributes
.and_then(|a| a.get("health_status"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Some((
"container.health_status".to_string(),
json!({
"id": id,
"name": name,
"health": health,
}),
))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn container_event(action: &str, extra_attrs: Value) -> Value {
let mut attributes = json!({
"name": "my-container",
"image": "docker.io/library/nginx:latest",
});
if let (Some(attrs_obj), Some(extra_obj)) =
(attributes.as_object_mut(), extra_attrs.as_object())
{
for (k, v) in extra_obj {
attrs_obj.insert(k.clone(), v.clone());
}
}
json!({
"Type": "container",
"Action": action,
"Actor": {
"ID": "abc123fullid",
"Attributes": attributes,
},
"Status": action,
"time": 1_700_000_000,
})
}
#[test]
fn maps_start_event() {
let event = container_event("start", json!({}));
let (kind, payload) = map_podman_event(&event).expect("should map start event");
assert_eq!(kind, "container.started");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
assert_eq!(payload["image"], "docker.io/library/nginx:latest");
}
#[test]
fn maps_died_event() {
let event = container_event("died", json!({}));
let (kind, payload) = map_podman_event(&event).expect("should map died event");
assert_eq!(kind, "container.stopped");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
}
#[test]
fn ignores_stop_event_to_avoid_double_emit_with_died() {
let event = container_event("stop", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn ignores_remove_event() {
let event = container_event("remove", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn maps_health_status_event() {
let event = container_event("health_status", json!({ "health_status": "healthy" }));
let (kind, payload) = map_podman_event(&event).expect("should map health_status event");
assert_eq!(kind, "container.health_status");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
assert_eq!(payload["health"], "healthy");
}
#[test]
fn ignores_unknown_action() {
let event = container_event("exec_die", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn ignores_non_container_type() {
let event = json!({
"Type": "network",
"Action": "start",
"Actor": { "ID": "netid", "Attributes": {} },
});
assert!(map_podman_event(&event).is_none());
}
#[test]
fn missing_fields_fall_back_to_unknown() {
let event = json!({
"Type": "container",
"Action": "start",
"Actor": { "ID": "onlyid" },
});
let (kind, payload) = map_podman_event(&event).expect("should still map");
assert_eq!(kind, "container.started");
assert_eq!(payload["id"], "onlyid");
assert_eq!(payload["name"], "unknown");
assert_eq!(payload["image"], "unknown");
}
}