mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
550 lines
20 KiB
Rust
550 lines
20 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Maps Podman container events to the compute-driver watch protocol.
|
|
|
|
use crate::client::{
|
|
ContainerInspect, ContainerListEntry, ContainerState, HealthState, PodmanApiError,
|
|
PodmanClient, PodmanEvent,
|
|
};
|
|
use crate::container::{LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, short_id};
|
|
use futures::Stream;
|
|
use openshell_core::ComputeDriverError;
|
|
use openshell_core::proto::compute::v1::{
|
|
DriverCondition, DriverSandbox, DriverSandboxStatus, WatchSandboxesDeletedEvent,
|
|
WatchSandboxesEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event,
|
|
};
|
|
use std::pin::Pin;
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
use tracing::{debug, info, warn};
|
|
|
|
// Condition reason constants shared across event-building paths.
|
|
const CONDITION_RUNNING: &str = "ContainerRunning";
|
|
const CONDITION_STARTING: &str = "ContainerStarting";
|
|
const CONDITION_EXITED: &str = "ContainerExited";
|
|
const CONDITION_STOPPED: &str = "ContainerStopped";
|
|
|
|
pub type WatchStream =
|
|
Pin<Box<dyn Stream<Item = Result<WatchSandboxesEvent, ComputeDriverError>> + Send>>;
|
|
|
|
/// Build a `WatchSandboxesEvent` carrying a sandbox snapshot.
|
|
fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent {
|
|
WatchSandboxesEvent {
|
|
payload: Some(watch_sandboxes_event::Payload::Sandbox(
|
|
WatchSandboxesSandboxEvent {
|
|
sandbox: Some(sandbox),
|
|
},
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Build a `WatchSandboxesEvent` for a deleted sandbox.
|
|
fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent {
|
|
WatchSandboxesEvent {
|
|
payload: Some(watch_sandboxes_event::Payload::Deleted(
|
|
WatchSandboxesDeletedEvent { sandbox_id },
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Start a watch stream that emits current state and live events.
|
|
///
|
|
/// The stream first emits a snapshot of all currently-running managed
|
|
/// sandboxes (initial state sync), then delivers live container events
|
|
/// as they arrive from the Podman event stream.
|
|
///
|
|
/// # Reconnection contract
|
|
///
|
|
/// The returned stream is **single-use**. When the Podman event connection
|
|
/// drops (daemon restart, socket error, or clean shutdown), the stream
|
|
/// terminates with a final error item and stops producing events.
|
|
///
|
|
/// Callers are responsible for reconnecting by calling [`start_watch`] again.
|
|
/// The server's `ComputeRuntime::watch_loop` in `openshell-server` provides
|
|
/// this behaviour with a 2-second backoff: when the stream terminates with an
|
|
/// error, `watch_loop` sleeps and then calls `watch_sandboxes()` again, which
|
|
/// ultimately calls `start_watch()` again and re-syncs state.
|
|
///
|
|
/// **Do not add reconnection logic inside this function.** A local reconnect
|
|
/// would race with `watch_loop`'s retry and produce duplicate initial-sync
|
|
/// events that corrupt the server's sandbox index.
|
|
pub async fn start_watch(client: PodmanClient) -> Result<WatchStream, PodmanApiError> {
|
|
let (tx, rx) = mpsc::channel::<Result<WatchSandboxesEvent, ComputeDriverError>>(256);
|
|
|
|
// 1. Subscribe to events first so we don't miss any during the list.
|
|
let mut event_rx = client.events_stream(LABEL_MANAGED_FILTER).await?;
|
|
|
|
// 2. List existing containers for initial state sync.
|
|
let existing = client.list_containers(LABEL_MANAGED_FILTER).await?;
|
|
|
|
for entry in &existing {
|
|
// For running containers, use inspect to get full state including
|
|
// health check status — matching the same condition derivation used
|
|
// for live events.
|
|
if entry.state == "running" {
|
|
match client.inspect_container(&entry.id).await {
|
|
Ok(inspect) => {
|
|
if let Some(sandbox) = driver_sandbox_from_inspect(&inspect) {
|
|
if tx.send(Ok(sandbox_event(sandbox))).await.is_err() {
|
|
return Err(PodmanApiError::Connection(
|
|
"watch receiver dropped during initial sync".into(),
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
container_id = %entry.id,
|
|
error = %e,
|
|
"Failed to inspect running container during initial sync, falling back to list entry"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if let Some(event) = driver_sandbox_from_list_entry(entry).map(sandbox_event)
|
|
&& tx.send(Ok(event)).await.is_err()
|
|
{
|
|
return Err(PodmanApiError::Connection(
|
|
"watch receiver dropped during initial sync".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// 3. Stream live events (buffered during the list operation above).
|
|
|
|
tokio::spawn(async move {
|
|
while let Some(result) = event_rx.recv().await {
|
|
match result {
|
|
Ok(event) => {
|
|
if let Some(we) = map_podman_event(&event, &client).await
|
|
&& tx.send(Ok(we)).await.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if tx
|
|
.send(Err(ComputeDriverError::Message(e.to_string())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The Podman event stream has ended — either because the Podman
|
|
// daemon restarted, the Unix socket was closed, or the connection
|
|
// dropped. We do NOT reconnect here; see the doc-comment on
|
|
// start_watch() for the full reconnection contract.
|
|
//
|
|
// Sending this error terminates the WatchStream seen by the caller.
|
|
// The server's watch_loop detects the terminal error, waits 2 seconds,
|
|
// then calls watch_sandboxes() → start_watch() again, which re-lists
|
|
// all containers and re-subscribes to events. This ensures a full
|
|
// state resync after any Podman daemon interruption.
|
|
warn!("podman event stream ended unexpectedly; watch_loop will reconnect");
|
|
let _ = tx
|
|
.send(Err(ComputeDriverError::Message(
|
|
"podman event stream ended unexpectedly".to_string(),
|
|
)))
|
|
.await;
|
|
});
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
/// Map a Podman event to an optional watch event.
|
|
///
|
|
/// Some events (like `remove`) produce a deletion event. State-change events
|
|
/// trigger a container inspect to build a full snapshot.
|
|
async fn map_podman_event(
|
|
event: &PodmanEvent,
|
|
client: &PodmanClient,
|
|
) -> Option<WatchSandboxesEvent> {
|
|
let container_id = &event.actor.id;
|
|
let sandbox_id = event
|
|
.actor
|
|
.attributes
|
|
.get(LABEL_SANDBOX_ID)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
if sandbox_id.is_empty() {
|
|
debug!(
|
|
container_id = %container_id,
|
|
action = %event.action,
|
|
"Ignoring event for container without sandbox-id label"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
match event.action.as_str() {
|
|
"remove" => Some(deleted_event(sandbox_id.clone())),
|
|
"create" | "start" | "stop" | "die" | "health_status" => {
|
|
// Inspect the container to get current state.
|
|
match client.inspect_container(container_id).await {
|
|
Ok(inspect) => driver_sandbox_from_inspect(&inspect).map(sandbox_event),
|
|
Err(PodmanApiError::NotFound(_)) => {
|
|
// The container is already gone by the time we inspected
|
|
// it. This is a normal race between the `die`/`stop` event
|
|
// and the subsequent `remove` event: Podman fires `die`
|
|
// first, but the container may be fully removed before we
|
|
// can inspect it. Treat this as a deletion so the server
|
|
// does not see a spurious phase regression.
|
|
info!(
|
|
container_id = %container_id,
|
|
action = %event.action,
|
|
"Container already removed when inspecting after event, emitting deleted event"
|
|
);
|
|
Some(deleted_event(sandbox_id.clone()))
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
container_id = %container_id,
|
|
error = %e,
|
|
"Failed to inspect container after event"
|
|
);
|
|
// Emit a synthetic event with the info we have so the
|
|
// server knows something happened.
|
|
let sandbox_name = event
|
|
.actor
|
|
.attributes
|
|
.get(LABEL_SANDBOX_NAME)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
Some(sandbox_event(build_driver_sandbox(
|
|
sandbox_id.clone(),
|
|
sandbox_name,
|
|
String::new(),
|
|
short_id(container_id),
|
|
DriverCondition {
|
|
r#type: "Ready".to_string(),
|
|
status: "Unknown".to_string(),
|
|
reason: "InspectFailed".to_string(),
|
|
message: format!("Container inspect failed: {e}"),
|
|
last_transition_time: String::new(),
|
|
},
|
|
false,
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
debug!(action = %event.action, "Ignoring unhandled Podman event");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Construct a `DriverSandbox` from common fields.
|
|
///
|
|
/// Centralises the boilerplate that every event/inspect/list path shares:
|
|
/// `namespace`, `spec`, `agent_fd`, and `sandbox_fd` are always empty in
|
|
/// the Podman driver.
|
|
fn build_driver_sandbox(
|
|
sandbox_id: String,
|
|
sandbox_name: String,
|
|
instance_name: String,
|
|
instance_id: String,
|
|
condition: DriverCondition,
|
|
deleting: bool,
|
|
) -> DriverSandbox {
|
|
DriverSandbox {
|
|
id: sandbox_id,
|
|
name: sandbox_name,
|
|
namespace: String::new(),
|
|
spec: None,
|
|
status: Some(DriverSandboxStatus {
|
|
sandbox_name: instance_name,
|
|
instance_id,
|
|
agent_fd: String::new(),
|
|
sandbox_fd: String::new(),
|
|
conditions: vec![condition],
|
|
deleting,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Build a `DriverSandbox` from a container inspection result.
|
|
pub fn driver_sandbox_from_inspect(inspect: &ContainerInspect) -> Option<DriverSandbox> {
|
|
let sandbox_id = inspect.config.labels.get(LABEL_SANDBOX_ID)?.clone();
|
|
let sandbox_name = inspect
|
|
.config
|
|
.labels
|
|
.get(LABEL_SANDBOX_NAME)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let condition = condition_from_state(&inspect.state);
|
|
let deleting = inspect.state.status == "removing";
|
|
|
|
Some(build_driver_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
inspect.name.trim_start_matches('/').to_string(),
|
|
short_id(&inspect.id),
|
|
condition,
|
|
deleting,
|
|
))
|
|
}
|
|
|
|
/// Build a `DriverSandbox` from a container list entry (no inspect needed).
|
|
pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option<DriverSandbox> {
|
|
let sandbox_id = entry.labels.get(LABEL_SANDBOX_ID)?.clone();
|
|
let sandbox_name = entry
|
|
.labels
|
|
.get(LABEL_SANDBOX_NAME)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let (reason, status_str, message) = match entry.state.as_str() {
|
|
"running" => (
|
|
CONDITION_RUNNING,
|
|
"True",
|
|
"Container is running".to_string(),
|
|
),
|
|
"created" => ("ContainerCreated", "False", String::new()),
|
|
"exited" => (CONDITION_EXITED, "False", String::new()),
|
|
"stopped" => (CONDITION_STOPPED, "False", String::new()),
|
|
"removing" => ("ContainerRemoving", "False", String::new()),
|
|
_ => ("Unknown", "Unknown", String::new()),
|
|
};
|
|
|
|
Some(build_driver_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
entry.names.first().cloned().unwrap_or_default(),
|
|
short_id(&entry.id),
|
|
DriverCondition {
|
|
r#type: "Ready".to_string(),
|
|
status: status_str.to_string(),
|
|
reason: reason.to_string(),
|
|
message,
|
|
last_transition_time: String::new(),
|
|
},
|
|
entry.state == "removing",
|
|
))
|
|
}
|
|
|
|
/// Derive a `DriverCondition` from Podman container state.
|
|
fn condition_from_state(state: &ContainerState) -> DriverCondition {
|
|
let (status_val, reason, message) = match state.status.as_str() {
|
|
"running" => match &state.health {
|
|
Some(HealthState { status }) if status == "healthy" => {
|
|
("True", "HealthCheckPassed", String::new())
|
|
}
|
|
Some(HealthState { status }) if status == "unhealthy" => {
|
|
("False", "HealthCheckFailed", String::new())
|
|
}
|
|
Some(HealthState { status }) if status == "starting" => {
|
|
("False", "HealthCheckStarting", String::new())
|
|
}
|
|
_ => ("False", CONDITION_STARTING, String::new()),
|
|
},
|
|
"created" => ("False", "ContainerCreated", String::new()),
|
|
"exited" | "stopped" => {
|
|
let msg = if state.oom_killed {
|
|
"Container was killed by the OOM killer".to_string()
|
|
} else {
|
|
format!("Container exited with code {}", state.exit_code)
|
|
};
|
|
let reason = if state.oom_killed {
|
|
"OOMKilled"
|
|
} else {
|
|
CONDITION_EXITED
|
|
};
|
|
("False", reason, msg)
|
|
}
|
|
other => (
|
|
"Unknown",
|
|
"Unknown",
|
|
format!("Unknown container state: {other}"),
|
|
),
|
|
};
|
|
|
|
// Use Podman's state timestamps for last_transition_time:
|
|
// - Running/healthy states use started_at
|
|
// - Stopped/exited states use finished_at
|
|
let last_transition_time = match state.status.as_str() {
|
|
"running" => state.started_at.clone().unwrap_or_default(),
|
|
"exited" | "stopped" => state.finished_at.clone().unwrap_or_default(),
|
|
_ => String::new(),
|
|
};
|
|
|
|
DriverCondition {
|
|
r#type: "Ready".to_string(),
|
|
status: status_val.to_string(),
|
|
reason: reason.to_string(),
|
|
message,
|
|
last_transition_time,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn condition_healthy_container() {
|
|
let state = ContainerState {
|
|
status: "running".to_string(),
|
|
running: true,
|
|
exit_code: 0,
|
|
oom_killed: false,
|
|
health: Some(HealthState {
|
|
status: "healthy".to_string(),
|
|
}),
|
|
started_at: Some("2026-04-14T10:00:00Z".to_string()),
|
|
finished_at: None,
|
|
};
|
|
let cond = condition_from_state(&state);
|
|
assert_eq!(cond.r#type, "Ready");
|
|
assert_eq!(cond.status, "True");
|
|
assert_eq!(cond.reason, "HealthCheckPassed");
|
|
assert_eq!(cond.last_transition_time, "2026-04-14T10:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn condition_oom_killed() {
|
|
let state = ContainerState {
|
|
status: "exited".to_string(),
|
|
running: false,
|
|
exit_code: 137,
|
|
oom_killed: true,
|
|
health: None,
|
|
started_at: None,
|
|
finished_at: Some("2026-04-14T11:00:00Z".to_string()),
|
|
};
|
|
let cond = condition_from_state(&state);
|
|
assert_eq!(cond.status, "False");
|
|
assert_eq!(cond.reason, "OOMKilled");
|
|
assert_eq!(cond.last_transition_time, "2026-04-14T11:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn condition_normal_exit() {
|
|
let state = ContainerState {
|
|
status: "exited".to_string(),
|
|
running: false,
|
|
exit_code: 1,
|
|
oom_killed: false,
|
|
health: None,
|
|
started_at: None,
|
|
finished_at: Some("2026-04-14T12:00:00Z".to_string()),
|
|
};
|
|
let cond = condition_from_state(&state);
|
|
assert_eq!(cond.status, "False");
|
|
assert_eq!(cond.reason, "ContainerExited");
|
|
assert!(cond.message.contains("code 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn short_id_truncates() {
|
|
assert_eq!(short_id("abc123def456789"), "abc123def456");
|
|
assert_eq!(short_id("short"), "short");
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_event_from_list_entry_running() {
|
|
let mut labels = std::collections::HashMap::new();
|
|
labels.insert(LABEL_SANDBOX_ID.to_string(), "test-id".to_string());
|
|
labels.insert(LABEL_SANDBOX_NAME.to_string(), "test-name".to_string());
|
|
|
|
let entry = ContainerListEntry {
|
|
id: "abc123def456789".to_string(),
|
|
names: vec!["openshell-sandbox-test-name".to_string()],
|
|
state: "running".to_string(),
|
|
labels,
|
|
ports: None,
|
|
networks: None,
|
|
exit_code: 0,
|
|
};
|
|
|
|
let sandbox = driver_sandbox_from_list_entry(&entry).expect("should produce a sandbox");
|
|
let status = sandbox.status.expect("should have status");
|
|
assert_eq!(status.conditions.len(), 1);
|
|
let cond = &status.conditions[0];
|
|
assert_eq!(cond.status, "True");
|
|
assert_eq!(cond.reason, "ContainerRunning");
|
|
assert!(!status.deleting);
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_inspect_failed_event_structure() {
|
|
// Verify the structure of an inspect-failure event by constructing one
|
|
// using the same pattern as the production code.
|
|
let condition = DriverCondition {
|
|
r#type: "Ready".to_string(),
|
|
status: "Unknown".to_string(),
|
|
reason: "InspectFailed".to_string(),
|
|
message: "Container inspect failed: connection refused".to_string(),
|
|
last_transition_time: String::new(),
|
|
};
|
|
|
|
let sandbox = DriverSandbox {
|
|
id: "sandbox-123".to_string(),
|
|
name: "test-sandbox".to_string(),
|
|
namespace: String::new(),
|
|
spec: None,
|
|
status: Some(DriverSandboxStatus {
|
|
sandbox_name: String::new(),
|
|
instance_id: short_id("container-id-full"),
|
|
agent_fd: String::new(),
|
|
sandbox_fd: String::new(),
|
|
conditions: vec![condition],
|
|
deleting: false,
|
|
}),
|
|
};
|
|
|
|
let event = WatchSandboxesEvent {
|
|
payload: Some(watch_sandboxes_event::Payload::Sandbox(
|
|
WatchSandboxesSandboxEvent {
|
|
sandbox: Some(sandbox),
|
|
},
|
|
)),
|
|
};
|
|
|
|
let payload = event.payload.unwrap();
|
|
let watch_sandboxes_event::Payload::Sandbox(sandbox_event) = payload else {
|
|
panic!("expected Sandbox payload")
|
|
};
|
|
let status = sandbox_event.sandbox.unwrap().status.unwrap();
|
|
assert_eq!(status.conditions.len(), 1);
|
|
let cond = &status.conditions[0];
|
|
assert_eq!(cond.reason, "InspectFailed");
|
|
assert_eq!(cond.status, "Unknown");
|
|
assert!(cond.message.contains("inspect failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn not_found_inspect_produces_deleted_event() {
|
|
// When inspect_container returns NotFound (404) after a `die` or
|
|
// `stop` event, the container is already gone. The watcher should emit
|
|
// a deleted_event rather than a synthetic InspectFailed sandbox event,
|
|
// preventing the server from regressing the sandbox phase back to
|
|
// Provisioning.
|
|
//
|
|
// We verify the shape of a deleted_event directly since
|
|
// map_podman_event is async and requires a live Podman client. The
|
|
// production code path is:
|
|
// Err(PodmanApiError::NotFound(_)) => Some(deleted_event(sandbox_id))
|
|
let sandbox_id = "sandbox-abc-123".to_string();
|
|
let event = deleted_event(sandbox_id.clone());
|
|
|
|
let payload = event.payload.expect("deleted event must have a payload");
|
|
match payload {
|
|
watch_sandboxes_event::Payload::Deleted(d) => {
|
|
assert_eq!(d.sandbox_id, sandbox_id);
|
|
}
|
|
other => {
|
|
panic!(
|
|
"expected Deleted payload, got {other:?} — a NotFound inspect must not produce a sandbox or platform event"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|